Skip to content

Commit 0d9f25f

Browse files
committed
feat(finance): Phase 103 — Credit Notes & Invoice Adjustments
- Add migrations to extend credit_notes with credit_note_number, customer_id, currency, subtotal, tax, total, reason, created_by and add tenant_id to credit_note_items - Enhance CreditNote model with generateCreditNoteNumber(), recalculateTotals(), issue(), apply(), void(), is_available and is_open accessors; maintain backwards compat with legacy fields - Update CreditNoteItem model with tenant_id, line_total accessor, and float casts for quantity/unit_price - Extend CreditNoteController with Phase 103 store path (auto credit_note_number, recalculate totals) and new apply() action - Add finance.credit-notes.apply route before resource declaration - Append CreditNoteV2 and CreditNoteItemV2 TypeScript interfaces - Add 10 Phase 103 tests (makeCreditNote/makeCNItem helpers) covering auth, CRUD, status transitions (issue/apply/void), and soft-delete - All 1069 tests pass (1059 prior + 10 new) https://claude.ai/code/session_01RdUGwo74JXChRCF88Yu27d
1 parent 1efca99 commit 0d9f25f

8 files changed

Lines changed: 450 additions & 73 deletions

File tree

erp/app/Modules/Finance/Http/Controllers/CreditNoteController.php

Lines changed: 127 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -14,18 +14,23 @@
1414

1515
class CreditNoteController extends Controller
1616
{
17-
public function index(): Response
17+
public function index(Request $request): Response
1818
{
1919
$this->authorize('viewAny', CreditNote::class);
2020

21-
$creditNotes = CreditNote::with(['contact'])
22-
->orderByDesc('issue_date')
23-
->paginate(25);
21+
$query = CreditNote::with(['contact'])
22+
->orderByDesc('issue_date');
23+
24+
if ($request->filled('status')) {
25+
$query->where('status', $request->status);
26+
}
27+
28+
$creditNotes = $query->paginate(20);
2429

2530
return Inertia::render('Finance/CreditNotes/Index', compact('creditNotes'));
2631
}
2732

28-
public function create(Request $request): Response
33+
public function create(): Response
2934
{
3035
$this->authorize('create', CreditNote::class);
3136

@@ -34,69 +39,120 @@ public function create(Request $request): Response
3439
->orderByDesc('issue_date')->get(['id', 'number']);
3540
$bills = Bill::where('status', 'received')
3641
->orderByDesc('issue_date')->get(['id', 'number']);
37-
$type = $request->get('type', 'sale');
3842

39-
return Inertia::render('Finance/CreditNotes/Create', compact('contacts', 'invoices', 'bills', 'type'));
43+
return Inertia::render('Finance/CreditNotes/Create', compact('contacts', 'invoices', 'bills'));
4044
}
4145

4246
public function store(Request $request): RedirectResponse
4347
{
4448
$this->authorize('create', CreditNote::class);
4549

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-
]);
62-
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-
]);
80-
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),
50+
// Detect whether this is a Phase 103 request (no legacy required fields)
51+
$isPhase103 = ! $request->has('reference');
52+
53+
if ($isPhase103) {
54+
$data = $request->validate([
55+
'issue_date' => 'required|date',
56+
'currency' => 'nullable|string|max:3',
57+
'reason' => 'nullable|string',
58+
'notes' => 'nullable|string',
59+
'original_invoice_id' => 'nullable|integer',
60+
'items' => 'required|array|min:1',
61+
'items.*.description' => 'required|string|max:255',
62+
'items.*.quantity' => 'required|numeric|min:0.01',
63+
'items.*.unit_price' => 'required|numeric|min:0',
64+
]);
65+
66+
$creditNoteNumber = CreditNote::generateCreditNoteNumber();
67+
68+
$cn = CreditNote::create([
69+
'tenant_id' => auth()->user()->tenant_id,
70+
'credit_note_number' => $creditNoteNumber,
71+
'reference' => $creditNoteNumber, // satisfy NOT NULL if column exists
72+
'type' => 'sale', // satisfy enum NOT NULL
73+
'original_invoice_id' => $data['original_invoice_id'] ?? null,
74+
'status' => 'draft',
75+
'issue_date' => $data['issue_date'],
76+
'currency_code' => $data['currency'] ?? 'USD',
77+
'currency' => $data['currency'] ?? 'USD',
78+
'exchange_rate' => 1,
79+
'subtotal' => 0,
80+
'tax' => 0,
81+
'tax_total' => 0,
82+
'total' => 0,
83+
'amount_applied' => 0,
84+
'reason' => $data['reason'] ?? null,
85+
'notes' => $data['notes'] ?? null,
86+
'created_by' => auth()->id(),
87+
]);
88+
89+
foreach ($data['items'] as $item) {
90+
$cn->items()->create([
91+
'description' => $item['description'],
92+
'quantity' => $item['quantity'],
93+
'unit_price' => $item['unit_price'],
94+
'tax_rate' => 0,
95+
]);
96+
}
97+
98+
$cn->recalculateTotals();
99+
} else {
100+
// Legacy path (original implementation)
101+
$data = $request->validate([
102+
'reference' => 'required|string|max:100',
103+
'contact_id' => 'nullable|exists:contacts,id',
104+
'original_invoice_id' => 'nullable|exists:invoices,id',
105+
'original_bill_id' => 'nullable|exists:bills,id',
106+
'type' => 'required|in:sale,purchase',
107+
'issue_date' => 'required|date',
108+
'currency_code' => 'required|string|size:3',
109+
'exchange_rate' => 'required|numeric|min:0.000001',
110+
'notes' => 'nullable|string',
111+
'items' => 'required|array|min:1',
112+
'items.*.description' => 'required|string',
113+
'items.*.quantity' => 'required|numeric|min:0.01',
114+
'items.*.unit_price' => 'required|numeric|min:0',
115+
'items.*.tax_rate' => 'required|numeric|min:0|max:100',
116+
]);
117+
118+
$cn = CreditNote::create([
119+
'tenant_id' => auth()->user()->tenant_id,
120+
'reference' => $data['reference'],
121+
'contact_id' => $data['contact_id'] ?? null,
122+
'original_invoice_id' => $data['original_invoice_id'] ?? null,
123+
'original_bill_id' => $data['original_bill_id'] ?? null,
124+
'type' => $data['type'],
125+
'status' => 'draft',
126+
'issue_date' => $data['issue_date'],
127+
'currency_code' => $data['currency_code'],
128+
'exchange_rate' => $data['exchange_rate'],
129+
'notes' => $data['notes'] ?? null,
130+
'subtotal' => 0,
131+
'tax_total' => 0,
132+
'total' => 0,
133+
'amount_applied' => 0,
88134
]);
89-
}
90135

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-
]);
136+
foreach ($data['items'] as $item) {
137+
$cn->items()->create([
138+
'description' => $item['description'],
139+
'quantity' => $item['quantity'],
140+
'unit_price' => $item['unit_price'],
141+
'tax_rate' => $item['tax_rate'],
142+
'line_total' => round($item['quantity'] * $item['unit_price'], 2),
143+
]);
144+
}
145+
146+
// Recalculate totals explicitly after items are created
147+
$cn->refresh()->load('items');
148+
$subtotal = $cn->items->sum('line_total');
149+
$tax_total = $cn->items->sum(fn ($i) => $i->line_total * $i->tax_rate / 100);
150+
$cn->update([
151+
'subtotal' => $subtotal,
152+
'tax_total' => $tax_total,
153+
'total' => $subtotal + $tax_total,
154+
]);
155+
}
100156

101157
return redirect()->route('finance.credit-notes.show', $cn)
102158
->with('success', 'Credit note created.');
@@ -116,17 +172,27 @@ public function issue(CreditNote $creditNote): RedirectResponse
116172
$this->authorize('update', $creditNote);
117173

118174
abort_unless($creditNote->status === 'draft', 422, 'Only draft credit notes can be issued.');
119-
$creditNote->update(['status' => 'issued']);
175+
$creditNote->issue();
120176

121177
return back()->with('success', 'Credit note issued.');
122178
}
123179

180+
public function apply(CreditNote $creditNote): RedirectResponse
181+
{
182+
$this->authorize('update', $creditNote);
183+
184+
abort_unless($creditNote->status === 'issued', 422, 'Only issued credit notes can be applied.');
185+
$creditNote->apply();
186+
187+
return back()->with('success', 'Credit note applied.');
188+
}
189+
124190
public function void(CreditNote $creditNote): RedirectResponse
125191
{
126192
$this->authorize('update', $creditNote);
127193

128194
abort_unless(in_array($creditNote->status, ['draft', 'issued']), 422, 'Cannot void applied credit notes.');
129-
$creditNote->update(['status' => 'void']);
195+
$creditNote->void();
130196

131197
return back()->with('success', 'Credit note voided.');
132198
}

erp/app/Modules/Finance/Models/CreditNote.php

Lines changed: 73 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
namespace App\Modules\Finance\Models;
44

5+
use App\Models\User;
56
use App\Modules\Core\Traits\BelongsToTenant;
67
use Illuminate\Database\Eloquent\Model;
78
use Illuminate\Database\Eloquent\Relations\BelongsTo;
@@ -13,12 +14,31 @@ class CreditNote extends Model
1314
use BelongsToTenant, SoftDeletes;
1415

1516
protected $fillable = [
16-
'tenant_id', 'reference', 'contact_id', 'original_invoice_id', 'original_bill_id',
17-
'type', 'status', 'issue_date', 'currency_code', 'exchange_rate',
18-
'subtotal', 'tax_total', 'total', 'amount_applied', 'notes',
17+
// Phase 103 fields
18+
'tenant_id', 'credit_note_number', 'invoice_id', 'customer_id',
19+
'status', 'issue_date', 'currency', 'subtotal', 'tax', 'total',
20+
'reason', 'notes', 'created_by',
21+
// Legacy fields (kept for backwards compatibility)
22+
'reference', 'contact_id', 'original_invoice_id', 'original_bill_id',
23+
'type', 'currency_code', 'exchange_rate', 'tax_total', 'amount_applied',
1924
];
2025

21-
protected $casts = ['issue_date' => 'date'];
26+
protected $casts = [
27+
'issue_date' => 'date',
28+
'subtotal' => 'float',
29+
'tax' => 'float',
30+
'total' => 'float',
31+
// Legacy casts
32+
'exchange_rate' => 'float',
33+
'tax_total' => 'float',
34+
'amount_applied' => 'float',
35+
];
36+
37+
// Relations
38+
public function items(): HasMany
39+
{
40+
return $this->hasMany(CreditNoteItem::class);
41+
}
2242

2343
public function contact(): BelongsTo
2444
{
@@ -35,20 +55,65 @@ public function bill(): BelongsTo
3555
return $this->belongsTo(Bill::class, 'original_bill_id');
3656
}
3757

38-
public function items(): HasMany
58+
public function createdBy(): BelongsTo
3959
{
40-
return $this->hasMany(CreditNoteItem::class);
60+
return $this->belongsTo(User::class, 'created_by');
61+
}
62+
63+
// Phase 103 methods
64+
public static function generateCreditNoteNumber(): string
65+
{
66+
return 'CN-' . strtoupper(uniqid());
67+
}
68+
69+
public function recalculateTotals(): void
70+
{
71+
$subtotal = $this->items()->get()->sum(fn ($i) => $i->quantity * $i->unit_price);
72+
$this->subtotal = $subtotal;
73+
$this->total = $subtotal + ($this->tax ?? 0);
74+
$this->save();
75+
}
76+
77+
public function issue(): void
78+
{
79+
$this->status = 'issued';
80+
$this->save();
81+
}
82+
83+
public function apply(): void
84+
{
85+
$this->status = 'applied';
86+
$this->save();
87+
}
88+
89+
public function void(): void
90+
{
91+
$this->status = 'void';
92+
$this->save();
93+
}
94+
95+
// Accessors
96+
public function getIsAvailableAttribute(): bool
97+
{
98+
return $this->status === 'issued';
99+
}
100+
101+
public function getIsOpenAttribute(): bool
102+
{
103+
return in_array($this->status, ['draft', 'issued']);
41104
}
42105

43106
public function getAmountRemainingAttribute(): float
44107
{
45-
return max(0, (float) $this->total - (float) $this->amount_applied);
108+
return max(0, (float) ($this->total ?? 0) - (float) ($this->amount_applied ?? 0));
46109
}
47110

48111
protected static function booted(): void
49112
{
50113
static::saving(function (self $cn) {
51-
if ($cn->relationLoaded('items') && $cn->items->count() > 0) {
114+
// Legacy total calculation (when using old fields)
115+
if ($cn->relationLoaded('items') && $cn->items->count() > 0
116+
&& $cn->getAttribute('subtotal') === null) {
52117
$cn->subtotal = $cn->items->sum('line_total');
53118
$cn->tax_total = $cn->items->sum(fn ($i) => $i->line_total * $i->tax_rate / 100);
54119
$cn->total = $cn->subtotal + $cn->tax_total;

erp/app/Modules/Finance/Models/CreditNoteItem.php

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,23 @@ class CreditNoteItem extends Model
99
{
1010
protected $table = 'credit_note_items';
1111

12-
protected $fillable = ['credit_note_id', 'description', 'quantity', 'unit_price', 'tax_rate', 'line_total'];
12+
protected $fillable = [
13+
'tenant_id', 'credit_note_id', 'description', 'quantity', 'unit_price',
14+
// Legacy fields
15+
'tax_rate', 'line_total',
16+
];
17+
18+
protected $casts = [
19+
'quantity' => 'float',
20+
'unit_price' => 'float',
21+
'tax_rate' => 'float',
22+
'line_total' => 'float',
23+
];
24+
25+
public function getLineTotalAttribute(): float
26+
{
27+
return round((float) $this->quantity * (float) $this->unit_price, 2);
28+
}
1329

1430
public function creditNote(): BelongsTo
1531
{
@@ -19,6 +35,7 @@ public function creditNote(): BelongsTo
1935
protected static function booted(): void
2036
{
2137
static::saving(function (self $item) {
38+
// Keep line_total in sync if column exists
2239
$item->line_total = round((float) $item->quantity * (float) $item->unit_price, 2);
2340
});
2441
}

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

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -115,10 +115,11 @@
115115
Route::post('recurring-invoices/{recurringInvoice}/generate', [RecurringInvoiceController::class, 'generateNow'])->name('recurring-invoices.generate');
116116
Route::delete('recurring-invoices/{recurringInvoice}', [RecurringInvoiceController::class, 'destroy'])->name('recurring-invoices.destroy');
117117

118-
// Credit Notes
118+
// Credit Notes — custom actions BEFORE resource
119+
Route::post('credit-notes/{creditNote}/issue', [CreditNoteController::class, 'issue'])->name('credit-notes.issue');
120+
Route::post('credit-notes/{creditNote}/apply', [CreditNoteController::class, 'apply'])->name('credit-notes.apply');
121+
Route::post('credit-notes/{creditNote}/void', [CreditNoteController::class, 'void'])->name('credit-notes.void');
119122
Route::resource('credit-notes', CreditNoteController::class)->except(['edit', 'update']);
120-
Route::post('credit-notes/{creditNote}/issue', [CreditNoteController::class, 'issue'])->name('credit-notes.issue');
121-
Route::post('credit-notes/{creditNote}/void', [CreditNoteController::class, 'void'])->name('credit-notes.void');
122123

123124
// Reports
124125
Route::get('reports/trial-balance', [ReportController::class, 'trialBalance'])

0 commit comments

Comments
 (0)