Skip to content

Commit 14463e0

Browse files
committed
feat(finance): Phase 114 — Bank Transfers between Accounts
Implements Bank Transfers feature for moving funds between bank accounts with full status lifecycle (pending → completed/failed/cancelled), RBAC policy, paginated index with status filtering, and 10 Pest feature tests. https://claude.ai/code/session_01RdUGwo74JXChRCF88Yu27d
1 parent fc2a1b4 commit 14463e0

11 files changed

Lines changed: 594 additions & 0 deletions

File tree

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
<?php
2+
3+
namespace App\Modules\Finance\Http\Controllers;
4+
5+
use App\Http\Controllers\Controller;
6+
use App\Modules\Finance\Models\BankTransfer;
7+
use Illuminate\Http\RedirectResponse;
8+
use Illuminate\Http\Request;
9+
use Inertia\Inertia;
10+
use Inertia\Response;
11+
12+
class BankTransferController extends Controller
13+
{
14+
public function index(Request $request): Response
15+
{
16+
$this->authorize('viewAny', BankTransfer::class);
17+
18+
$query = BankTransfer::where('tenant_id', $request->user()->tenant_id)
19+
->with(['fromAccount', 'toAccount']);
20+
21+
if ($request->filled('status')) {
22+
$query->where('status', $request->status);
23+
}
24+
25+
$transfers = $query->latest()->paginate(20);
26+
27+
return Inertia::render('Finance/BankTransfers/Index', [
28+
'transfers' => $transfers,
29+
'filters' => $request->only(['status']),
30+
]);
31+
}
32+
33+
public function store(Request $request): RedirectResponse
34+
{
35+
$this->authorize('create', BankTransfer::class);
36+
37+
$data = $request->validate([
38+
'from_account_id' => 'required|exists:bank_accounts,id|different:to_account_id',
39+
'to_account_id' => 'required|exists:bank_accounts,id',
40+
'amount' => 'required|numeric|min:0.01',
41+
'transfer_date' => 'required|date',
42+
'currency' => 'nullable|string|max:3',
43+
'reference' => 'nullable|string|max:255',
44+
'notes' => 'nullable|string',
45+
]);
46+
47+
$transfer = BankTransfer::create([
48+
...$data,
49+
'tenant_id' => $request->user()->tenant_id,
50+
'created_by' => $request->user()->id,
51+
'currency' => $data['currency'] ?? 'USD',
52+
]);
53+
54+
return redirect()->route('finance.bank-transfers.show', $transfer)
55+
->with('success', 'Bank transfer created.');
56+
}
57+
58+
public function show(BankTransfer $bankTransfer): Response
59+
{
60+
$this->authorize('view', $bankTransfer);
61+
62+
$bankTransfer->load(['fromAccount', 'toAccount', 'createdBy']);
63+
64+
return Inertia::render('Finance/BankTransfers/Show', [
65+
'transfer' => $bankTransfer,
66+
]);
67+
}
68+
69+
public function complete(BankTransfer $bankTransfer): RedirectResponse
70+
{
71+
$this->authorize('create', BankTransfer::class);
72+
73+
$bankTransfer->complete();
74+
75+
return back()->with('success', 'Transfer marked as completed.');
76+
}
77+
78+
public function fail(BankTransfer $bankTransfer): RedirectResponse
79+
{
80+
$this->authorize('create', BankTransfer::class);
81+
82+
$bankTransfer->fail();
83+
84+
return back()->with('success', 'Transfer marked as failed.');
85+
}
86+
87+
public function cancel(BankTransfer $bankTransfer): RedirectResponse
88+
{
89+
$this->authorize('create', BankTransfer::class);
90+
91+
$bankTransfer->cancel();
92+
93+
return back()->with('success', 'Transfer cancelled.');
94+
}
95+
96+
public function destroy(BankTransfer $bankTransfer): RedirectResponse
97+
{
98+
$this->authorize('delete', $bankTransfer);
99+
100+
$bankTransfer->delete();
101+
102+
return redirect()->route('finance.bank-transfers.index')
103+
->with('success', 'Bank transfer deleted.');
104+
}
105+
}
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
<?php
2+
3+
namespace App\Modules\Finance\Models;
4+
5+
use App\Models\User;
6+
use App\Modules\Core\Traits\BelongsToTenant;
7+
use Illuminate\Database\Eloquent\Model;
8+
use Illuminate\Database\Eloquent\Relations\BelongsTo;
9+
use Illuminate\Database\Eloquent\SoftDeletes;
10+
11+
class BankTransfer extends Model
12+
{
13+
use BelongsToTenant, SoftDeletes;
14+
15+
protected $fillable = [
16+
'tenant_id', 'from_account_id', 'to_account_id', 'amount',
17+
'currency', 'transfer_date', 'reference', 'status', 'notes',
18+
'created_by', 'processed_at',
19+
];
20+
21+
protected $casts = [
22+
'amount' => 'float',
23+
'transfer_date' => 'date',
24+
'processed_at' => 'datetime',
25+
];
26+
27+
public function fromAccount(): BelongsTo
28+
{
29+
return $this->belongsTo(BankAccount::class, 'from_account_id');
30+
}
31+
32+
public function toAccount(): BelongsTo
33+
{
34+
return $this->belongsTo(BankAccount::class, 'to_account_id');
35+
}
36+
37+
public function createdBy(): BelongsTo
38+
{
39+
return $this->belongsTo(User::class, 'created_by');
40+
}
41+
42+
public function complete(): void
43+
{
44+
$this->status = 'completed';
45+
$this->processed_at = now();
46+
$this->save();
47+
}
48+
49+
public function fail(): void
50+
{
51+
$this->status = 'failed';
52+
$this->processed_at = now();
53+
$this->save();
54+
}
55+
56+
public function cancel(): void
57+
{
58+
$this->status = 'cancelled';
59+
$this->save();
60+
}
61+
62+
public function getIsPendingAttribute(): bool
63+
{
64+
return $this->status === 'pending';
65+
}
66+
67+
public function getIsCompletedAttribute(): bool
68+
{
69+
return $this->status === 'completed';
70+
}
71+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
<?php
2+
3+
namespace App\Modules\Finance\Policies;
4+
5+
use App\Models\User;
6+
use App\Modules\Finance\Models\BankTransfer;
7+
8+
class BankTransferPolicy
9+
{
10+
public function viewAny(User $user): bool { return $user->can('finance.view'); }
11+
public function view(User $user, BankTransfer $bankTransfer): bool { return $user->can('finance.view'); }
12+
public function create(User $user): bool { return $user->can('finance.create'); }
13+
public function delete(User $user, BankTransfer $bankTransfer): bool { return $user->can('finance.delete'); }
14+
}

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,8 @@
9393
use App\Modules\Finance\Models\PettyCashFund;
9494
use App\Modules\Finance\Models\PettyCashTransaction;
9595
use App\Modules\Finance\Policies\PettyCashPolicy;
96+
use App\Modules\Finance\Models\BankTransfer;
97+
use App\Modules\Finance\Policies\BankTransferPolicy;
9698
use Illuminate\Support\Facades\Gate;
9799
use Illuminate\Support\ServiceProvider;
98100

@@ -167,6 +169,7 @@ public function boot(): void
167169
Gate::policy(PaymentTerm::class, PaymentTermPolicy::class);
168170
Gate::policy(PettyCashFund::class, PettyCashPolicy::class);
169171
Gate::policy(PettyCashTransaction::class, PettyCashPolicy::class);
172+
Gate::policy(BankTransfer::class, BankTransferPolicy::class);
170173
if ($this->app->runningInConsole()) {
171174
$this->commands([\App\Modules\Finance\Console\Commands\GenerateRecurringInvoices::class]);
172175
}

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

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -342,3 +342,12 @@
342342
Route::post('petty-cash/{pettyCashFund}/expense', [PettyCashFundController::class, 'expense'])->name('petty-cash.expense');
343343
Route::resource('petty-cash', PettyCashFundController::class)->except(['create', 'edit', 'update']);
344344
});
345+
346+
// Bank Transfers — custom actions BEFORE resource
347+
use App\Modules\Finance\Http\Controllers\BankTransferController;
348+
Route::middleware(['web', 'auth', 'verified'])->prefix('finance')->name('finance.')->group(function () {
349+
Route::post('bank-transfers/{bankTransfer}/complete', [BankTransferController::class, 'complete'])->name('bank-transfers.complete');
350+
Route::post('bank-transfers/{bankTransfer}/fail', [BankTransferController::class, 'fail'])->name('bank-transfers.fail');
351+
Route::post('bank-transfers/{bankTransfer}/cancel', [BankTransferController::class, 'cancel'])->name('bank-transfers.cancel');
352+
Route::resource('bank-transfers', BankTransferController::class)->except(['create', 'edit', 'update']);
353+
});
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('bank_transfers', function (Blueprint $table) {
12+
$table->id();
13+
$table->unsignedBigInteger('tenant_id');
14+
$table->unsignedBigInteger('from_account_id');
15+
$table->unsignedBigInteger('to_account_id');
16+
$table->decimal('amount', 15, 2);
17+
$table->string('currency', 3)->default('USD');
18+
$table->date('transfer_date');
19+
$table->string('reference')->nullable();
20+
$table->string('status')->default('pending');
21+
$table->text('notes')->nullable();
22+
$table->unsignedBigInteger('created_by')->nullable();
23+
$table->timestamp('processed_at')->nullable();
24+
$table->timestamps();
25+
$table->softDeletes();
26+
});
27+
}
28+
29+
public function down(): void
30+
{
31+
Schema::dropIfExists('bank_transfers');
32+
}
33+
};

erp/resources/js/Components/Layout/Sidebar.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,7 @@ const navItems: NavItem[] = [
149149
{ label: 'Vendor Bills', href: '/finance/vendor-bills', icon: <span /> },
150150
{ label: 'Payment Terms', href: '/finance/payment-terms', icon: <span /> },
151151
{ label: 'Petty Cash', href: '/finance/petty-cash', icon: <span /> },
152+
{ label: 'Bank Transfers', href: '/finance/bank-transfers', icon: <span /> },
152153
],
153154
},
154155
{
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
import React from 'react';
2+
import { Head, Link, router } from '@inertiajs/react';
3+
import AppLayout from '@/Layouts/AppLayout';
4+
import { BankTransfer } from '@/types/finance';
5+
6+
interface Props {
7+
transfers: {
8+
data: BankTransfer[];
9+
links: { url: string | null; label: string; active: boolean }[];
10+
total: number;
11+
};
12+
filters: { status?: string };
13+
}
14+
15+
export default function Index({ transfers, filters }: Props) {
16+
return (
17+
<AppLayout>
18+
<Head title="Bank Transfers" />
19+
<div className="mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
20+
<div className="mb-6 flex items-center justify-between">
21+
<h1 className="text-2xl font-semibold text-slate-900">Bank Transfers</h1>
22+
<Link
23+
href="/finance/bank-transfers/create"
24+
className="rounded-lg bg-indigo-600 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-700"
25+
>
26+
New Transfer
27+
</Link>
28+
</div>
29+
30+
<div className="overflow-hidden rounded-lg border border-slate-200 bg-white shadow-sm">
31+
<table className="min-w-full divide-y divide-slate-200">
32+
<thead className="bg-slate-50">
33+
<tr>
34+
<th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wide text-slate-500">Date</th>
35+
<th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wide text-slate-500">From</th>
36+
<th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wide text-slate-500">To</th>
37+
<th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wide text-slate-500">Amount</th>
38+
<th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wide text-slate-500">Status</th>
39+
<th className="px-6 py-3" />
40+
</tr>
41+
</thead>
42+
<tbody className="divide-y divide-slate-100">
43+
{transfers.data.map((transfer) => (
44+
<tr key={transfer.id} className="hover:bg-slate-50">
45+
<td className="px-6 py-4 text-sm text-slate-700">{transfer.transfer_date}</td>
46+
<td className="px-6 py-4 text-sm text-slate-700">
47+
{(transfer as any).from_account?.name ?? transfer.from_account_id}
48+
</td>
49+
<td className="px-6 py-4 text-sm text-slate-700">
50+
{(transfer as any).to_account?.name ?? transfer.to_account_id}
51+
</td>
52+
<td className="px-6 py-4 text-sm text-slate-700">
53+
{transfer.currency} {Number(transfer.amount).toFixed(2)}
54+
</td>
55+
<td className="px-6 py-4 text-sm">
56+
<span className={`inline-flex rounded-full px-2 py-0.5 text-xs font-medium ${
57+
transfer.status === 'completed' ? 'bg-green-100 text-green-700' :
58+
transfer.status === 'failed' ? 'bg-red-100 text-red-700' :
59+
transfer.status === 'cancelled' ? 'bg-slate-100 text-slate-600' :
60+
'bg-yellow-100 text-yellow-700'
61+
}`}>
62+
{transfer.status}
63+
</span>
64+
</td>
65+
<td className="px-6 py-4 text-right text-sm">
66+
<Link
67+
href={`/finance/bank-transfers/${transfer.id}`}
68+
className="text-indigo-600 hover:text-indigo-800"
69+
>
70+
View
71+
</Link>
72+
</td>
73+
</tr>
74+
))}
75+
{transfers.data.length === 0 && (
76+
<tr>
77+
<td colSpan={6} className="px-6 py-10 text-center text-sm text-slate-400">
78+
No bank transfers found.
79+
</td>
80+
</tr>
81+
)}
82+
</tbody>
83+
</table>
84+
</div>
85+
</div>
86+
</AppLayout>
87+
);
88+
}

0 commit comments

Comments
 (0)