Skip to content

Commit ce39d39

Browse files
committed
feat(finance): Phase 125 — Finance Intercompany Transactions
https://claude.ai/code/session_01RdUGwo74JXChRCF88Yu27d
1 parent a71f322 commit ce39d39

9 files changed

Lines changed: 327 additions & 1 deletion

File tree

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
<?php
2+
3+
namespace App\Modules\Finance\Http\Controllers;
4+
5+
use App\Http\Controllers\Controller;
6+
use App\Modules\Finance\Models\IntercompanyTransaction;
7+
use Illuminate\Http\RedirectResponse;
8+
use Illuminate\Http\Request;
9+
use Inertia\Inertia;
10+
use Inertia\Response;
11+
12+
class IntercompanyController extends Controller
13+
{
14+
public function index(): Response
15+
{
16+
$this->authorize('viewAny', IntercompanyTransaction::class);
17+
$transactions = IntercompanyTransaction::where('tenant_id', app('tenant')->id)
18+
->latest()
19+
->paginate(20);
20+
return Inertia::render('Finance/Intercompany/Index', compact('transactions'));
21+
}
22+
23+
public function store(Request $request): RedirectResponse
24+
{
25+
$this->authorize('create', IntercompanyTransaction::class);
26+
$validated = $request->validate([
27+
'from_entity' => 'required|string|max:255',
28+
'to_entity' => 'required|string|max:255',
29+
'amount' => 'required|numeric|min:0.01',
30+
'currency' => 'nullable|string|max:3',
31+
'transaction_date' => 'required|date',
32+
'transaction_type' => 'required|string|max:50',
33+
'description' => 'nullable|string',
34+
]);
35+
$validated['tenant_id'] = app('tenant')->id;
36+
$validated['created_by'] = auth()->id();
37+
IntercompanyTransaction::create($validated);
38+
return back()->with('success', 'Transaction created.');
39+
}
40+
41+
public function show(IntercompanyTransaction $intercompany): Response
42+
{
43+
$this->authorize('view', $intercompany);
44+
return Inertia::render('Finance/Intercompany/Show', ['transaction' => $intercompany]);
45+
}
46+
47+
public function post(IntercompanyTransaction $intercompany): RedirectResponse
48+
{
49+
$this->authorize('update', $intercompany);
50+
$intercompany->post();
51+
return back()->with('success', 'Transaction posted.');
52+
}
53+
54+
public function reconcile(IntercompanyTransaction $intercompany): RedirectResponse
55+
{
56+
$this->authorize('update', $intercompany);
57+
$intercompany->reconcile();
58+
return back()->with('success', 'Transaction reconciled.');
59+
}
60+
61+
public function reverse(IntercompanyTransaction $intercompany): RedirectResponse
62+
{
63+
$this->authorize('update', $intercompany);
64+
$intercompany->reverse();
65+
return back()->with('success', 'Transaction reversed.');
66+
}
67+
68+
public function destroy(IntercompanyTransaction $intercompany): RedirectResponse
69+
{
70+
$this->authorize('delete', $intercompany);
71+
$intercompany->delete();
72+
return back()->with('success', 'Transaction deleted.');
73+
}
74+
}
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
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\SoftDeletes;
8+
9+
class IntercompanyTransaction extends Model
10+
{
11+
use BelongsToTenant, SoftDeletes;
12+
13+
protected $attributes = [
14+
'status' => 'draft',
15+
'currency' => 'USD',
16+
];
17+
18+
protected $fillable = [
19+
'tenant_id', 'transaction_number', 'from_entity', 'to_entity',
20+
'amount', 'currency', 'transaction_date', 'transaction_type',
21+
'description', 'status', 'created_by',
22+
];
23+
24+
protected $casts = [
25+
'amount' => 'float',
26+
'transaction_date' => 'date',
27+
];
28+
29+
public function generateTransactionNumber(): string
30+
{
31+
return 'IC-' . date('Y') . '-' . str_pad((string) $this->id, 5, '0', STR_PAD_LEFT);
32+
}
33+
34+
public function post(): void
35+
{
36+
$this->status = 'posted';
37+
if (is_null($this->transaction_number)) {
38+
$this->transaction_number = $this->generateTransactionNumber();
39+
}
40+
$this->save();
41+
}
42+
43+
public function reconcile(): void
44+
{
45+
$this->status = 'reconciled';
46+
$this->save();
47+
}
48+
49+
public function reverse(): void
50+
{
51+
$this->status = 'reversed';
52+
$this->save();
53+
}
54+
55+
public function getIsPostedAttribute(): bool
56+
{
57+
return $this->status === 'posted';
58+
}
59+
60+
public function getIsDraftAttribute(): bool
61+
{
62+
return $this->status === 'draft';
63+
}
64+
}
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\IntercompanyTransaction;
7+
8+
class IntercompanyPolicy
9+
{
10+
public function viewAny(User $user): bool
11+
{
12+
return $user->hasPermissionTo('finance.view');
13+
}
14+
15+
public function view(User $user, IntercompanyTransaction $transaction): 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, IntercompanyTransaction $transaction): bool
26+
{
27+
return $user->hasPermissionTo('finance.create');
28+
}
29+
30+
public function delete(User $user, IntercompanyTransaction $transaction): bool
31+
{
32+
return $user->hasPermissionTo('finance.delete');
33+
}
34+
}

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

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,8 @@
104104
use App\Modules\Finance\Policies\DebitNotePolicy;
105105
use App\Modules\Finance\Models\WriteOff;
106106
use App\Modules\Finance\Policies\WriteOffPolicy;
107+
use App\Modules\Finance\Models\IntercompanyTransaction;
108+
use App\Modules\Finance\Policies\IntercompanyPolicy;
107109
use Illuminate\Support\Facades\Gate;
108110
use Illuminate\Support\ServiceProvider;
109111

@@ -183,7 +185,8 @@ public function boot(): void
183185
Gate::policy(AdvancePayment::class, AdvancePaymentPolicy::class);
184186
Gate::policy(DebitNote::class, DebitNotePolicy::class);
185187
Gate::policy(DebitNoteItem::class, DebitNotePolicy::class);
186-
Gate::policy(WriteOff::class, WriteOffPolicy::class);
188+
Gate::policy(WriteOff::class, WriteOffPolicy::class);
189+
Gate::policy(IntercompanyTransaction::class, IntercompanyPolicy::class);
187190
if ($this->app->runningInConsole()) {
188191
$this->commands([\App\Modules\Finance\Console\Commands\GenerateRecurringInvoices::class]);
189192
}

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

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -383,3 +383,12 @@
383383
Route::post('write-offs/{writeOff}/reverse', [WriteOffController::class, 'reverse'])->name('write-offs.reverse');
384384
Route::resource('write-offs', WriteOffController::class)->only(['index', 'store', 'show', 'destroy']);
385385
});
386+
387+
// Intercompany Transactions — custom actions BEFORE resource
388+
use App\Modules\Finance\Http\Controllers\IntercompanyController;
389+
Route::middleware(['web', 'auth', 'verified'])->prefix('finance')->name('finance.')->group(function () {
390+
Route::post('intercompany/{intercompany}/post', [IntercompanyController::class, 'post'])->name('intercompany.post');
391+
Route::post('intercompany/{intercompany}/reconcile', [IntercompanyController::class, 'reconcile'])->name('intercompany.reconcile');
392+
Route::post('intercompany/{intercompany}/reverse', [IntercompanyController::class, 'reverse'])->name('intercompany.reverse');
393+
Route::resource('intercompany', IntercompanyController::class)->only(['index', 'store', 'show', 'destroy']);
394+
});
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
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('intercompany_transactions', function (Blueprint $table) {
12+
$table->id();
13+
$table->unsignedBigInteger('tenant_id');
14+
$table->string('transaction_number')->nullable();
15+
$table->string('from_entity'); // name/identifier of sending entity
16+
$table->string('to_entity'); // name/identifier of receiving entity
17+
$table->decimal('amount', 15, 2);
18+
$table->string('currency')->default('USD');
19+
$table->date('transaction_date');
20+
$table->string('transaction_type'); // loan/dividend/recharge/transfer/other
21+
$table->text('description')->nullable();
22+
$table->string('status')->default('draft'); // draft/posted/reconciled/reversed
23+
$table->unsignedBigInteger('created_by')->nullable();
24+
$table->timestamps();
25+
$table->softDeletes();
26+
});
27+
}
28+
29+
public function down(): void
30+
{
31+
Schema::dropIfExists('intercompany_transactions');
32+
}
33+
};
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
import React from 'react';
2+
3+
export default function IntercompanyIndex() {
4+
return <div>Intercompany Transactions</div>;
5+
}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
import React from 'react';
2+
3+
export default function IntercompanyShow() {
4+
return <div>Intercompany Transaction</div>;
5+
}
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
<?php
2+
3+
use App\Models\User;
4+
use App\Modules\Core\Models\Tenant;
5+
use App\Modules\Finance\Models\IntercompanyTransaction;
6+
use Database\Seeders\RolePermissionSeeder;
7+
8+
beforeEach(function () {
9+
$this->seed(RolePermissionSeeder::class);
10+
$this->tenant = Tenant::create(['name' => 'ICcorp', 'slug' => 'ic-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 makeICTransaction(array $attrs = []): IntercompanyTransaction
18+
{
19+
return IntercompanyTransaction::create([
20+
'tenant_id' => test()->tenant->id,
21+
'from_entity' => 'Parent Co',
22+
'to_entity' => 'Subsidiary A',
23+
'amount' => 10000.00,
24+
'transaction_date' => now()->toDateString(),
25+
'transaction_type' => 'recharge',
26+
'created_by' => test()->admin->id,
27+
...$attrs,
28+
]);
29+
}
30+
31+
it('index requires authentication', function () {
32+
$this->post('/logout');
33+
$this->get('/finance/intercompany')->assertRedirect('/login');
34+
});
35+
36+
it('admin can list intercompany transactions', function () {
37+
makeICTransaction();
38+
$this->get('/finance/intercompany')->assertOk();
39+
});
40+
41+
it('store creates an intercompany transaction', function () {
42+
$this->post('/finance/intercompany', [
43+
'from_entity' => 'HQ',
44+
'to_entity' => 'Branch',
45+
'amount' => 5000.00,
46+
'transaction_date' => now()->toDateString(),
47+
'transaction_type' => 'loan',
48+
])->assertRedirect();
49+
50+
expect(IntercompanyTransaction::where('tenant_id', test()->tenant->id)->where('transaction_type', 'loan')->exists())->toBeTrue();
51+
});
52+
53+
it('store validates required fields', function () {
54+
$this->postJson('/finance/intercompany', [])->assertStatus(422)->assertJsonValidationErrors(['from_entity', 'to_entity', 'amount', 'transaction_date', 'transaction_type']);
55+
});
56+
57+
it('show displays a transaction', function () {
58+
$tx = makeICTransaction();
59+
$this->get("/finance/intercompany/{$tx->id}")->assertOk();
60+
});
61+
62+
it('post transitions status to posted', function () {
63+
$tx = makeICTransaction();
64+
expect($tx->is_draft)->toBeTrue();
65+
66+
$this->post("/finance/intercompany/{$tx->id}/post")->assertRedirect();
67+
68+
$tx->refresh();
69+
expect($tx->is_posted)->toBeTrue();
70+
expect($tx->transaction_number)->not->toBeNull();
71+
});
72+
73+
it('reconcile transitions status to reconciled', function () {
74+
$tx = makeICTransaction(['status' => 'posted']);
75+
$this->post("/finance/intercompany/{$tx->id}/reconcile")->assertRedirect();
76+
$tx->refresh();
77+
expect($tx->status)->toBe('reconciled');
78+
});
79+
80+
it('reverse transitions status to reversed', function () {
81+
$tx = makeICTransaction(['status' => 'posted']);
82+
$this->post("/finance/intercompany/{$tx->id}/reverse")->assertRedirect();
83+
$tx->refresh();
84+
expect($tx->status)->toBe('reversed');
85+
});
86+
87+
it('transaction_number starts with IC- after posting', function () {
88+
$tx = makeICTransaction();
89+
$this->post("/finance/intercompany/{$tx->id}/post")->assertRedirect();
90+
$tx->refresh();
91+
expect($tx->transaction_number)->toStartWith('IC-');
92+
});
93+
94+
it('destroy soft-deletes the transaction', function () {
95+
$tx = makeICTransaction();
96+
$this->delete("/finance/intercompany/{$tx->id}")->assertRedirect();
97+
expect(IntercompanyTransaction::find($tx->id))->toBeNull();
98+
expect(IntercompanyTransaction::withTrashed()->find($tx->id))->not->toBeNull();
99+
});

0 commit comments

Comments
 (0)