Skip to content

Commit cc3c57a

Browse files
committed
feat(finance): Phase 117 — Advance Payments & Customer Deposits
Implements advance payment recording and tracking before invoices are issued, including migration, model with applyAmount/refund methods, CRUD controller, policy, routes, React pages, TypeScript types, sidebar link, and 10 Pest tests. https://claude.ai/code/session_01RdUGwo74JXChRCF88Yu27d
1 parent 7212d64 commit cc3c57a

11 files changed

Lines changed: 527 additions & 0 deletions

File tree

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
<?php
2+
3+
namespace App\Modules\Finance\Http\Controllers;
4+
5+
use App\Http\Controllers\Controller;
6+
use App\Modules\Finance\Models\AdvancePayment;
7+
use Illuminate\Http\RedirectResponse;
8+
use Illuminate\Http\Request;
9+
use Inertia\Inertia;
10+
use Inertia\Response;
11+
12+
class AdvancePaymentController extends Controller
13+
{
14+
public function index(Request $request): Response
15+
{
16+
$this->authorize('viewAny', AdvancePayment::class);
17+
18+
$query = AdvancePayment::with(['contact'])
19+
->orderByDesc('payment_date');
20+
21+
if ($request->filled('status')) {
22+
$query->where('status', $request->status);
23+
}
24+
25+
$advancePayments = $query->paginate(20);
26+
27+
return Inertia::render('Finance/AdvancePayments/Index', compact('advancePayments'));
28+
}
29+
30+
public function store(Request $request): RedirectResponse
31+
{
32+
$this->authorize('create', AdvancePayment::class);
33+
34+
$data = $request->validate([
35+
'contact_id' => 'nullable|exists:contacts,id',
36+
'amount' => 'required|numeric|min:0.01',
37+
'payment_date' => 'required|date',
38+
'currency' => 'nullable|string|max:3',
39+
'reference' => 'nullable|string|max:255',
40+
'payment_method' => 'nullable|string|max:50',
41+
'notes' => 'nullable|string',
42+
]);
43+
44+
$advancePayment = AdvancePayment::create([
45+
'tenant_id' => auth()->user()->tenant_id,
46+
'contact_id' => $data['contact_id'] ?? null,
47+
'amount' => $data['amount'],
48+
'payment_date' => $data['payment_date'],
49+
'currency' => $data['currency'] ?? 'USD',
50+
'reference' => $data['reference'] ?? null,
51+
'payment_method' => $data['payment_method'] ?? null,
52+
'notes' => $data['notes'] ?? null,
53+
'status' => 'received',
54+
'applied_amount' => 0,
55+
'created_by' => auth()->id(),
56+
]);
57+
58+
return redirect()->route('finance.advance-payments.show', $advancePayment)
59+
->with('success', 'Advance payment recorded.');
60+
}
61+
62+
public function show(AdvancePayment $advancePayment): Response
63+
{
64+
$this->authorize('view', $advancePayment);
65+
66+
$advancePayment->load(['contact', 'createdBy']);
67+
68+
return Inertia::render('Finance/AdvancePayments/Show', compact('advancePayment'));
69+
}
70+
71+
public function apply(Request $request, AdvancePayment $advancePayment): RedirectResponse
72+
{
73+
$this->authorize('update', $advancePayment);
74+
75+
$data = $request->validate([
76+
'amount' => 'required|numeric|min:0.01',
77+
]);
78+
79+
$advancePayment->applyAmount((float) $data['amount']);
80+
81+
return back()->with('success', 'Amount applied successfully.');
82+
}
83+
84+
public function refund(AdvancePayment $advancePayment): RedirectResponse
85+
{
86+
$this->authorize('update', $advancePayment);
87+
88+
$advancePayment->refund();
89+
90+
return back()->with('success', 'Advance payment refunded.');
91+
}
92+
93+
public function destroy(AdvancePayment $advancePayment): RedirectResponse
94+
{
95+
$this->authorize('delete', $advancePayment);
96+
97+
$advancePayment->delete();
98+
99+
return redirect()->route('finance.advance-payments.index')
100+
->with('success', 'Advance payment deleted.');
101+
}
102+
}
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
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 AdvancePayment extends Model
12+
{
13+
use BelongsToTenant, SoftDeletes;
14+
15+
protected $fillable = [
16+
'tenant_id', 'contact_id', 'reference', 'amount', 'applied_amount',
17+
'currency', 'payment_date', 'status', 'payment_method', 'notes',
18+
'created_by', 'refunded_at',
19+
];
20+
21+
protected $casts = [
22+
'amount' => 'float',
23+
'applied_amount' => 'float',
24+
'payment_date' => 'date',
25+
'refunded_at' => 'datetime',
26+
];
27+
28+
public function contact(): BelongsTo
29+
{
30+
return $this->belongsTo(Contact::class);
31+
}
32+
33+
public function createdBy(): BelongsTo
34+
{
35+
return $this->belongsTo(User::class, 'created_by');
36+
}
37+
38+
public function applyAmount(float $amount): void
39+
{
40+
$this->applied_amount = min($this->applied_amount + $amount, $this->amount);
41+
$this->status = $this->applied_amount >= $this->amount ? 'fully_applied' : 'partially_applied';
42+
$this->save();
43+
}
44+
45+
public function refund(): void
46+
{
47+
$this->status = 'refunded';
48+
$this->refunded_at = now();
49+
$this->save();
50+
}
51+
52+
public function getRemainingAmountAttribute(): float
53+
{
54+
return max(0.0, $this->amount - $this->applied_amount);
55+
}
56+
57+
public function getIsAvailableAttribute(): bool
58+
{
59+
return $this->status !== 'refunded' && $this->remaining_amount > 0;
60+
}
61+
}
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\AdvancePayment;
7+
8+
class AdvancePaymentPolicy
9+
{
10+
public function viewAny(User $user): bool
11+
{
12+
return $user->can('finance.view');
13+
}
14+
15+
public function view(User $user, AdvancePayment $advancePayment): 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, AdvancePayment $advancePayment): bool
26+
{
27+
return $user->can('finance.create');
28+
}
29+
30+
public function delete(User $user, AdvancePayment $advancePayment): bool
31+
{
32+
return $user->can('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
@@ -95,7 +95,9 @@
9595
use App\Modules\Finance\Policies\PettyCashPolicy;
9696
use App\Modules\Finance\Models\BankTransfer;
9797
use App\Modules\Finance\Policies\BankTransferPolicy;
98+
use App\Modules\Finance\Models\AdvancePayment;
9899
use App\Modules\Finance\Models\CustomerGroup;
100+
use App\Modules\Finance\Policies\AdvancePaymentPolicy;
99101
use App\Modules\Finance\Policies\CustomerGroupPolicy;
100102
use Illuminate\Support\Facades\Gate;
101103
use Illuminate\Support\ServiceProvider;
@@ -173,6 +175,7 @@ public function boot(): void
173175
Gate::policy(PettyCashTransaction::class, PettyCashPolicy::class);
174176
Gate::policy(BankTransfer::class, BankTransferPolicy::class);
175177
Gate::policy(CustomerGroup::class, CustomerGroupPolicy::class);
178+
Gate::policy(AdvancePayment::class, AdvancePaymentPolicy::class);
176179
if ($this->app->runningInConsole()) {
177180
$this->commands([\App\Modules\Finance\Console\Commands\GenerateRecurringInvoices::class]);
178181
}

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

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -357,3 +357,11 @@
357357
Route::post('bank-transfers/{bankTransfer}/cancel', [BankTransferController::class, 'cancel'])->name('bank-transfers.cancel');
358358
Route::resource('bank-transfers', BankTransferController::class)->except(['create', 'edit', 'update']);
359359
});
360+
361+
// Advance Payments — custom actions BEFORE resource
362+
use App\Modules\Finance\Http\Controllers\AdvancePaymentController;
363+
Route::middleware(['web', 'auth', 'verified'])->prefix('finance')->name('finance.')->group(function () {
364+
Route::post('advance-payments/{advancePayment}/apply', [AdvancePaymentController::class, 'apply'])->name('advance-payments.apply');
365+
Route::post('advance-payments/{advancePayment}/refund', [AdvancePaymentController::class, 'refund'])->name('advance-payments.refund');
366+
Route::resource('advance-payments', AdvancePaymentController::class)->except(['create', 'edit', 'update']);
367+
});
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
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('advance_payments', function (Blueprint $table) {
12+
$table->id();
13+
$table->unsignedBigInteger('tenant_id');
14+
$table->unsignedBigInteger('contact_id')->nullable();
15+
$table->string('reference')->nullable();
16+
$table->decimal('amount', 15, 2);
17+
$table->decimal('applied_amount', 15, 2)->default(0);
18+
$table->string('currency', 3)->default('USD');
19+
$table->date('payment_date');
20+
$table->string('status')->default('received');
21+
$table->string('payment_method')->nullable();
22+
$table->text('notes')->nullable();
23+
$table->unsignedBigInteger('created_by')->nullable();
24+
$table->timestamp('refunded_at')->nullable();
25+
$table->timestamps();
26+
$table->softDeletes();
27+
});
28+
}
29+
30+
public function down(): void
31+
{
32+
Schema::dropIfExists('advance_payments');
33+
}
34+
};

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,7 @@ const navItems: NavItem[] = [
151151
{ label: 'Payment Terms', href: '/finance/payment-terms', icon: <span /> },
152152
{ label: 'Petty Cash', href: '/finance/petty-cash', icon: <span /> },
153153
{ label: 'Bank Transfers', href: '/finance/bank-transfers', icon: <span /> },
154+
{ label: 'Advance Payments', href: '/finance/advance-payments', icon: <span /> },
154155
],
155156
},
156157
{
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import { Head, Link } from '@inertiajs/react';
2+
import AppLayout from '@/Layouts/AppLayout';
3+
import { Table } from '@/Components/Common/Table';
4+
import { Button } from '@/Components/Common/Button';
5+
import { Pagination } from '@/Components/Inventory/Pagination';
6+
import { usePermission } from '@/Hooks/usePermission';
7+
import type { PageProps } from '@/types';
8+
import type { AdvancePayment } from '@/types/finance';
9+
import type { Paginator } from '@/types/inventory';
10+
11+
interface Props extends PageProps {
12+
advancePayments: Paginator<AdvancePayment>;
13+
}
14+
15+
export default function AdvancePaymentsIndex({ advancePayments }: Props) {
16+
const { can } = usePermission();
17+
18+
return (
19+
<AppLayout>
20+
<Head title="Advance Payments" />
21+
<div className="space-y-6">
22+
<div className="flex items-center justify-between">
23+
<div>
24+
<h1 className="text-2xl font-semibold text-slate-900">Advance Payments</h1>
25+
<p className="text-sm text-slate-500 mt-1">{advancePayments.total} advance payments</p>
26+
</div>
27+
<div className="flex gap-2">
28+
{can('finance.create') && (
29+
<Link href="/finance/advance-payments/create"><Button>New Advance Payment</Button></Link>
30+
)}
31+
</div>
32+
</div>
33+
34+
<div className="rounded-lg border border-slate-200 bg-white shadow-sm">
35+
<Table
36+
columns={[
37+
{ key: 'reference', header: 'Reference', render: (ap) => (
38+
<Link href={`/finance/advance-payments/${ap.id}`} className="font-mono text-sm font-medium text-indigo-600 hover:text-indigo-800">
39+
{ap.reference ?? `AP-${ap.id}`}
40+
</Link>
41+
)},
42+
{ key: 'contact', header: 'Customer', render: (ap) => (ap as any).contact?.name ?? '—' },
43+
{ key: 'payment_date', header: 'Payment Date', render: (ap) => ap.payment_date },
44+
{ key: 'amount', header: 'Amount', render: (ap) => `${ap.currency} ${Number(ap.amount).toFixed(2)}` },
45+
{ key: 'applied_amount', header: 'Applied', render: (ap) => Number(ap.applied_amount).toFixed(2) },
46+
{ key: 'remaining_amount', header: 'Remaining', render: (ap) => Number(ap.remaining_amount).toFixed(2) },
47+
{ key: 'status', header: 'Status', render: (ap) => (
48+
<span className="capitalize rounded-full bg-slate-100 px-2 py-0.5 text-xs font-medium text-slate-700">
49+
{ap.status.replace('_', ' ')}
50+
</span>
51+
)},
52+
]}
53+
data={advancePayments.data}
54+
emptyMessage="No advance payments found."
55+
/>
56+
<Pagination paginator={advancePayments} />
57+
</div>
58+
</div>
59+
</AppLayout>
60+
);
61+
}

0 commit comments

Comments
 (0)