Skip to content

Commit 555a749

Browse files
committed
feat(finance): Phase 123 — Finance Write-offs (Bad Debt)
https://claude.ai/code/session_01RdUGwo74JXChRCF88Yu27d
1 parent c399476 commit 555a749

9 files changed

Lines changed: 327 additions & 0 deletions

File tree

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
<?php
2+
3+
namespace App\Modules\Finance\Http\Controllers;
4+
5+
use App\Http\Controllers\Controller;
6+
use App\Modules\Finance\Models\WriteOff;
7+
use Illuminate\Http\RedirectResponse;
8+
use Illuminate\Http\Request;
9+
use Inertia\Inertia;
10+
use Inertia\Response;
11+
12+
class WriteOffController extends Controller
13+
{
14+
public function index(): Response
15+
{
16+
$this->authorize('viewAny', WriteOff::class);
17+
$writeOffs = WriteOff::where('tenant_id', app('tenant')->id)
18+
->latest()
19+
->paginate(20);
20+
return Inertia::render('Finance/WriteOffs/Index', compact('writeOffs'));
21+
}
22+
23+
public function store(Request $request): RedirectResponse
24+
{
25+
$this->authorize('create', WriteOff::class);
26+
$validated = $request->validate([
27+
'customer_id' => 'nullable|exists:contacts,id',
28+
'invoice_id' => 'nullable',
29+
'amount' => 'required|numeric|min:0.01',
30+
'currency' => 'nullable|string|max:3',
31+
'write_off_date' => 'required|date',
32+
'reason' => 'required|string|max:255',
33+
'notes' => 'nullable|string',
34+
]);
35+
$validated['tenant_id'] = app('tenant')->id;
36+
$validated['created_by'] = auth()->id();
37+
WriteOff::create($validated);
38+
return back()->with('success', 'Write-off created.');
39+
}
40+
41+
public function show(WriteOff $writeOff): Response
42+
{
43+
$this->authorize('view', $writeOff);
44+
return Inertia::render('Finance/WriteOffs/Show', compact('writeOff'));
45+
}
46+
47+
public function approve(WriteOff $writeOff): RedirectResponse
48+
{
49+
$this->authorize('update', $writeOff);
50+
$writeOff->approve(auth()->id());
51+
return back()->with('success', 'Write-off approved.');
52+
}
53+
54+
public function reverse(WriteOff $writeOff): RedirectResponse
55+
{
56+
$this->authorize('update', $writeOff);
57+
$writeOff->reverse();
58+
return back()->with('success', 'Write-off reversed.');
59+
}
60+
61+
public function destroy(WriteOff $writeOff): RedirectResponse
62+
{
63+
$this->authorize('delete', $writeOff);
64+
$writeOff->delete();
65+
return back()->with('success', 'Write-off deleted.');
66+
}
67+
}
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
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+
use Illuminate\Database\Eloquent\SoftDeletes;
9+
10+
class WriteOff extends Model
11+
{
12+
use BelongsToTenant, SoftDeletes;
13+
14+
protected $fillable = [
15+
'tenant_id',
16+
'write_off_number',
17+
'customer_id',
18+
'invoice_id',
19+
'amount',
20+
'currency',
21+
'write_off_date',
22+
'reason',
23+
'notes',
24+
'status',
25+
'approved_by',
26+
'approved_at',
27+
'created_by',
28+
];
29+
30+
protected $attributes = [
31+
'status' => 'pending',
32+
'currency' => 'USD',
33+
];
34+
35+
protected $casts = [
36+
'amount' => 'float',
37+
'write_off_date' => 'date',
38+
'approved_at' => 'datetime',
39+
];
40+
41+
// Methods
42+
43+
public function generateWriteOffNumber(): string
44+
{
45+
return 'WO-' . date('Y') . '-' . str_pad((string) $this->id, 5, '0', STR_PAD_LEFT);
46+
}
47+
48+
public function approve(int $userId): void
49+
{
50+
$this->status = 'approved';
51+
$this->approved_by = $userId;
52+
$this->approved_at = now();
53+
if (is_null($this->write_off_number)) {
54+
$this->write_off_number = $this->generateWriteOffNumber();
55+
}
56+
$this->save();
57+
}
58+
59+
public function reverse(): void
60+
{
61+
$this->status = 'reversed';
62+
$this->save();
63+
}
64+
65+
// Accessors
66+
67+
public function getIsPendingAttribute(): bool
68+
{
69+
return $this->status === 'pending';
70+
}
71+
72+
public function getIsApprovedAttribute(): bool
73+
{
74+
return $this->status === 'approved';
75+
}
76+
77+
// Relations
78+
79+
public function customer(): BelongsTo
80+
{
81+
return $this->belongsTo(Contact::class, 'customer_id');
82+
}
83+
84+
public function invoice(): BelongsTo
85+
{
86+
return $this->belongsTo(Invoice::class, 'invoice_id');
87+
}
88+
}
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\WriteOff;
7+
8+
class WriteOffPolicy
9+
{
10+
public function viewAny(User $user): bool
11+
{
12+
return $user->hasPermissionTo('finance.view');
13+
}
14+
15+
public function view(User $user, WriteOff $writeOff): bool
16+
{
17+
return $user->hasPermissionTo('finance.view');
18+
}
19+
20+
public function create(User $user): bool
21+
{
22+
return $user->hasPermissionTo('finance.create');
23+
}
24+
25+
public function update(User $user, WriteOff $writeOff): bool
26+
{
27+
return $user->hasPermissionTo('finance.create');
28+
}
29+
30+
public function delete(User $user, WriteOff $writeOff): bool
31+
{
32+
return $user->hasPermissionTo('finance.delete');
33+
}
34+
}

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,8 @@
102102
use App\Modules\Finance\Models\DebitNote;
103103
use App\Modules\Finance\Models\DebitNoteItem;
104104
use App\Modules\Finance\Policies\DebitNotePolicy;
105+
use App\Modules\Finance\Models\WriteOff;
106+
use App\Modules\Finance\Policies\WriteOffPolicy;
105107
use Illuminate\Support\Facades\Gate;
106108
use Illuminate\Support\ServiceProvider;
107109

@@ -181,6 +183,7 @@ public function boot(): void
181183
Gate::policy(AdvancePayment::class, AdvancePaymentPolicy::class);
182184
Gate::policy(DebitNote::class, DebitNotePolicy::class);
183185
Gate::policy(DebitNoteItem::class, DebitNotePolicy::class);
186+
Gate::policy(WriteOff::class, WriteOffPolicy::class);
184187
if ($this->app->runningInConsole()) {
185188
$this->commands([\App\Modules\Finance\Console\Commands\GenerateRecurringInvoices::class]);
186189
}

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

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -375,3 +375,11 @@
375375
Route::post('debit-notes/{debitNote}/void', [DebitNoteController::class, 'void'])->name('debit-notes.void');
376376
Route::resource('debit-notes', DebitNoteController::class)->only(['index', 'store', 'show', 'destroy']);
377377
});
378+
379+
// Write-offs — custom actions BEFORE resource
380+
use App\Modules\Finance\Http\Controllers\WriteOffController;
381+
Route::middleware(['web', 'auth', 'verified'])->prefix('finance')->name('finance.')->group(function () {
382+
Route::post('write-offs/{writeOff}/approve', [WriteOffController::class, 'approve'])->name('write-offs.approve');
383+
Route::post('write-offs/{writeOff}/reverse', [WriteOffController::class, 'reverse'])->name('write-offs.reverse');
384+
Route::resource('write-offs', WriteOffController::class)->only(['index', 'store', 'show', 'destroy']);
385+
});
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
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('write_offs', function (Blueprint $table) {
12+
$table->id();
13+
$table->unsignedBigInteger('tenant_id');
14+
$table->string('write_off_number')->nullable();
15+
$table->unsignedBigInteger('customer_id')->nullable(); // references contacts
16+
$table->unsignedBigInteger('invoice_id')->nullable(); // references invoices
17+
$table->decimal('amount', 15, 2);
18+
$table->string('currency')->default('USD');
19+
$table->date('write_off_date');
20+
$table->string('reason'); // bad_debt/dispute/other
21+
$table->text('notes')->nullable();
22+
$table->string('status')->default('pending'); // pending/approved/reversed
23+
$table->unsignedBigInteger('approved_by')->nullable();
24+
$table->timestamp('approved_at')->nullable();
25+
$table->unsignedBigInteger('created_by')->nullable();
26+
$table->timestamps();
27+
$table->softDeletes();
28+
});
29+
}
30+
31+
public function down(): void
32+
{
33+
Schema::dropIfExists('write_offs');
34+
}
35+
};
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export default function Index() { return <div>Index</div>; }
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export default function Show() { return <div>Show</div>; }
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
<?php
2+
3+
use App\Models\User;
4+
use App\Modules\Core\Models\Tenant;
5+
use App\Modules\Finance\Models\WriteOff;
6+
use Database\Seeders\RolePermissionSeeder;
7+
8+
beforeEach(function () {
9+
$this->seed(RolePermissionSeeder::class);
10+
$this->tenant = Tenant::create(['name' => 'WOcorp', 'slug' => 'wo-corp-' . uniqid()]);
11+
$this->admin = User::factory()->create(['tenant_id' => $this->tenant->id]);
12+
$this->admin->assignRole('super-admin');
13+
$this->actingAs($this->admin);
14+
app()->instance('tenant', $this->tenant);
15+
});
16+
17+
function makeWriteOff(array $attrs = []): WriteOff
18+
{
19+
return WriteOff::create([
20+
'tenant_id' => test()->tenant->id,
21+
'amount' => 500.00,
22+
'write_off_date' => now()->toDateString(),
23+
'reason' => 'bad_debt',
24+
'created_by' => test()->admin->id,
25+
...$attrs,
26+
]);
27+
}
28+
29+
it('index requires authentication', function () {
30+
$this->post('/logout');
31+
$this->get('/finance/write-offs')->assertRedirect('/login');
32+
});
33+
34+
it('admin can list write-offs', function () {
35+
makeWriteOff();
36+
$this->get('/finance/write-offs')->assertOk();
37+
});
38+
39+
it('store creates a write-off', function () {
40+
$this->post('/finance/write-offs', [
41+
'amount' => 1000.00,
42+
'write_off_date' => now()->toDateString(),
43+
'reason' => 'dispute',
44+
])->assertRedirect();
45+
46+
expect(WriteOff::where('tenant_id', test()->tenant->id)->where('reason', 'dispute')->exists())->toBeTrue();
47+
});
48+
49+
it('store validates required fields', function () {
50+
$this->postJson('/finance/write-offs', [])->assertStatus(422)->assertJsonValidationErrors(['amount', 'write_off_date', 'reason']);
51+
});
52+
53+
it('show displays a write-off', function () {
54+
$wo = makeWriteOff();
55+
$this->get("/finance/write-offs/{$wo->id}")->assertOk();
56+
});
57+
58+
it('approve transitions to approved status', function () {
59+
$wo = makeWriteOff();
60+
expect($wo->is_pending)->toBeTrue();
61+
62+
$this->post("/finance/write-offs/{$wo->id}/approve")->assertRedirect();
63+
64+
$wo->refresh();
65+
expect($wo->is_approved)->toBeTrue();
66+
expect($wo->approved_by)->toBe(test()->admin->id);
67+
expect($wo->approved_at)->not->toBeNull();
68+
expect($wo->write_off_number)->not->toBeNull();
69+
});
70+
71+
it('reverse transitions to reversed status', function () {
72+
$wo = makeWriteOff(['status' => 'approved']);
73+
$this->post("/finance/write-offs/{$wo->id}/reverse")->assertRedirect();
74+
$wo->refresh();
75+
expect($wo->status)->toBe('reversed');
76+
});
77+
78+
it('write_off_number is generated on approval', function () {
79+
$wo = makeWriteOff();
80+
$this->post("/finance/write-offs/{$wo->id}/approve")->assertRedirect();
81+
$wo->refresh();
82+
expect($wo->write_off_number)->toStartWith('WO-');
83+
});
84+
85+
it('destroy soft-deletes the write-off', function () {
86+
$wo = makeWriteOff();
87+
$this->delete("/finance/write-offs/{$wo->id}")->assertRedirect();
88+
expect(WriteOff::find($wo->id))->toBeNull();
89+
expect(WriteOff::withTrashed()->find($wo->id))->not->toBeNull();
90+
});

0 commit comments

Comments
 (0)