Skip to content

Commit 60a728c

Browse files
committed
feat: Phase 39 — Batch Payments for invoices and bills
https://claude.ai/code/session_01RdUGwo74JXChRCF88Yu27d
1 parent 8cd0ce5 commit 60a728c

17 files changed

Lines changed: 1076 additions & 3 deletions

File tree

Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
<?php
2+
3+
namespace App\Modules\Finance\Http\Controllers;
4+
5+
use App\Http\Controllers\Controller;
6+
use App\Modules\Finance\Models\BatchPayment;
7+
use App\Modules\Finance\Models\Bill;
8+
use App\Modules\Finance\Models\BillPayment;
9+
use App\Modules\Finance\Models\Invoice;
10+
use App\Modules\Finance\Models\Payment;
11+
use Illuminate\Http\RedirectResponse;
12+
use Illuminate\Http\Request;
13+
use Illuminate\Support\Facades\DB;
14+
use Inertia\Inertia;
15+
use Inertia\Response;
16+
17+
class BatchPaymentController extends Controller
18+
{
19+
public function index(): Response
20+
{
21+
$this->authorize('viewAny', BatchPayment::class);
22+
23+
$batches = BatchPayment::withCount('payments')
24+
->orderByDesc('payment_date')
25+
->paginate(25);
26+
27+
return Inertia::render('Finance/BatchPayments/Index', compact('batches'));
28+
}
29+
30+
public function create(Request $request): Response
31+
{
32+
$this->authorize('create', BatchPayment::class);
33+
34+
$type = $request->get('type', 'received');
35+
36+
if ($type === 'received') {
37+
$openItems = Invoice::with('contact')
38+
->whereIn('status', ['sent', 'partial'])
39+
->orderBy('due_date')
40+
->get(['id', 'number', 'contact_id', 'due_date', 'currency_code', 'status']);
41+
} else {
42+
$openItems = Bill::with('contact')
43+
->whereIn('status', ['received', 'partial'])
44+
->orderBy('due_date')
45+
->get(['id', 'number', 'contact_id', 'due_date', 'currency_code', 'status']);
46+
}
47+
48+
// Load items and payments for computed totals
49+
$openItems->load(['items', 'payments']);
50+
51+
return Inertia::render('Finance/BatchPayments/Create', compact('openItems', 'type'));
52+
}
53+
54+
public function store(Request $request): RedirectResponse
55+
{
56+
$this->authorize('create', BatchPayment::class);
57+
58+
$data = $request->validate([
59+
'reference' => 'required|string|max:100|unique:batch_payments,reference',
60+
'payment_date' => 'required|date',
61+
'payment_method' => 'required|in:bank_transfer,cheque,cash,card,other',
62+
'type' => 'required|in:received,made',
63+
'notes' => 'nullable|string',
64+
'payments' => 'required|array|min:1',
65+
'payments.*.id' => 'required|integer',
66+
'payments.*.amount' => 'required|numeric|min:0.01',
67+
]);
68+
69+
DB::transaction(function () use ($data) {
70+
$totalAmount = collect($data['payments'])->sum('amount');
71+
72+
$batch = BatchPayment::create([
73+
'tenant_id' => auth()->user()->tenant_id,
74+
'reference' => $data['reference'],
75+
'payment_date' => $data['payment_date'],
76+
'payment_method' => $data['payment_method'],
77+
'type' => $data['type'],
78+
'total_amount' => $totalAmount,
79+
'notes' => $data['notes'] ?? null,
80+
]);
81+
82+
foreach ($data['payments'] as $p) {
83+
if ($data['type'] === 'received') {
84+
$invoice = Invoice::with(['items', 'payments'])->findOrFail($p['id']);
85+
$outstanding = $invoice->total - $invoice->amount_paid;
86+
$amount = min((float) $p['amount'], $outstanding);
87+
88+
if ($amount <= 0) {
89+
continue;
90+
}
91+
92+
Payment::create([
93+
'tenant_id' => auth()->user()->tenant_id,
94+
'invoice_id' => $invoice->id,
95+
'amount' => $amount,
96+
'payment_date' => $data['payment_date'],
97+
'method' => $data['payment_method'],
98+
'reference' => $data['reference'],
99+
'batch_payment_id' => $batch->id,
100+
]);
101+
102+
// Reload to get updated amount_paid
103+
$invoice->load(['items', 'payments']);
104+
if ($invoice->amount_due <= 0 && $invoice->canTransitionTo('paid')) {
105+
$invoice->transitionTo('paid');
106+
} elseif ($invoice->amount_paid > 0 && $invoice->canTransitionTo('partial')) {
107+
$invoice->transitionTo('partial');
108+
}
109+
} else {
110+
$bill = Bill::with(['items', 'payments'])->findOrFail($p['id']);
111+
$outstanding = $bill->total - $bill->amount_paid;
112+
$amount = min((float) $p['amount'], $outstanding);
113+
114+
if ($amount <= 0) {
115+
continue;
116+
}
117+
118+
BillPayment::create([
119+
'tenant_id' => auth()->user()->tenant_id,
120+
'bill_id' => $bill->id,
121+
'amount' => $amount,
122+
'payment_date' => $data['payment_date'],
123+
'method' => $data['payment_method'],
124+
'reference' => $data['reference'],
125+
]);
126+
127+
// Reload to get updated amount_paid
128+
$bill->load(['items', 'payments']);
129+
if ($bill->amount_due <= 0 && $bill->canTransitionTo('paid')) {
130+
$bill->transitionTo('paid');
131+
} elseif ($bill->amount_paid > 0 && $bill->canTransitionTo('partial')) {
132+
$bill->transitionTo('partial');
133+
}
134+
}
135+
}
136+
});
137+
138+
return redirect()->route('finance.batch-payments.index')
139+
->with('success', 'Batch payment recorded.');
140+
}
141+
142+
public function show(BatchPayment $batchPayment): Response
143+
{
144+
$this->authorize('view', $batchPayment);
145+
146+
$batchPayment->load('payments.invoice');
147+
148+
return Inertia::render('Finance/BatchPayments/Show', compact('batchPayment'));
149+
}
150+
151+
public function destroy(BatchPayment $batchPayment): RedirectResponse
152+
{
153+
$this->authorize('delete', $batchPayment);
154+
155+
$batchPayment->delete();
156+
157+
return redirect()->route('finance.batch-payments.index')
158+
->with('success', 'Batch payment deleted.');
159+
}
160+
}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
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+
use Illuminate\Database\Eloquent\Relations\HasMany;
9+
10+
class BatchPayment extends Model
11+
{
12+
use BelongsToTenant, SoftDeletes;
13+
14+
protected $fillable = [
15+
'tenant_id', 'reference', 'payment_date', 'payment_method',
16+
'type', 'total_amount', 'notes',
17+
];
18+
19+
protected $casts = [
20+
'payment_date' => 'date',
21+
'total_amount' => 'decimal:2',
22+
];
23+
24+
public function payments(): HasMany
25+
{
26+
return $this->hasMany(Payment::class);
27+
}
28+
}

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,8 @@ protected function getTransitions(): array
4040
{
4141
return [
4242
'draft' => ['received', 'cancelled'],
43-
'received' => ['paid', 'cancelled'],
43+
'received' => ['partial', 'paid', 'cancelled'],
44+
'partial' => ['paid', 'cancelled'],
4445
'paid' => [],
4546
'cancelled' => [],
4647
];

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,8 @@ protected function getTransitions(): array
4040
{
4141
return [
4242
'draft' => ['sent', 'cancelled'],
43-
'sent' => ['paid', 'cancelled'],
43+
'sent' => ['partial', 'paid', 'cancelled'],
44+
'partial' => ['paid', 'cancelled'],
4445
'paid' => [],
4546
'cancelled' => [],
4647
];

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

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ class Payment extends Model
1414

1515
protected $fillable = [
1616
'tenant_id', 'invoice_id', 'amount',
17-
'payment_date', 'method', 'reference', 'notes',
17+
'payment_date', 'method', 'reference', 'notes', 'batch_payment_id',
1818
];
1919

2020
protected $casts = [
@@ -26,4 +26,9 @@ public function invoice(): BelongsTo
2626
{
2727
return $this->belongsTo(Invoice::class);
2828
}
29+
30+
public function batchPayment(): BelongsTo
31+
{
32+
return $this->belongsTo(BatchPayment::class);
33+
}
2934
}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
<?php
2+
3+
namespace App\Modules\Finance\Policies;
4+
5+
use App\Models\User;
6+
use App\Modules\Finance\Models\BatchPayment;
7+
8+
class BatchPaymentPolicy
9+
{
10+
public function viewAny(User $user): bool
11+
{
12+
return $user->can('finance.view');
13+
}
14+
15+
public function view(User $user, BatchPayment $batchPayment): 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 delete(User $user, BatchPayment $batchPayment): bool
26+
{
27+
return $user->can('finance.delete');
28+
}
29+
}

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,10 @@
1919
use App\Modules\Finance\Models\DepreciationEntry;
2020
use App\Modules\Finance\Models\FixedAsset;
2121
use App\Modules\Finance\Models\Attachment;
22+
use App\Modules\Finance\Models\BatchPayment;
2223
use App\Modules\Finance\Models\Project;
2324
use App\Modules\Finance\Policies\AttachmentPolicy;
25+
use App\Modules\Finance\Policies\BatchPaymentPolicy;
2426
use App\Modules\Finance\Policies\AccountPolicy;
2527
use App\Modules\Finance\Policies\PriceListPolicy;
2628
use App\Modules\Finance\Policies\ProjectPolicy;
@@ -66,6 +68,7 @@ public function boot(): void
6668
Gate::policy(PriceList::class, PriceListPolicy::class);
6769
Gate::policy(Project::class, ProjectPolicy::class);
6870
Gate::policy(Attachment::class, AttachmentPolicy::class);
71+
Gate::policy(BatchPayment::class, BatchPaymentPolicy::class);
6972

7073
if ($this->app->runningInConsole()) {
7174
$this->commands([\App\Modules\Finance\Console\Commands\GenerateRecurringInvoices::class]);

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
use App\Modules\Finance\Http\Controllers\FixedAssetController;
1919
use App\Modules\Finance\Http\Controllers\PriceListController;
2020
use App\Modules\Finance\Http\Controllers\AttachmentController;
21+
use App\Modules\Finance\Http\Controllers\BatchPaymentController;
2122
use App\Modules\Finance\Http\Controllers\ProjectController;
2223
use Illuminate\Support\Facades\Route;
2324

@@ -151,6 +152,9 @@
151152
Route::post('projects/{project}/time-entries', [ProjectController::class, 'storeTimeEntry'])->name('projects.time-entries.store');
152153
Route::post('projects/{project}/mark-billed', [ProjectController::class, 'markBilled'])->name('projects.mark-billed');
153154

155+
// Batch Payments
156+
Route::resource('batch-payments', BatchPaymentController::class)->except(['edit', 'update']);
157+
154158
// File Attachments
155159
Route::post('attachments/{modelType}/{modelId}', [AttachmentController::class, 'store'])->name('attachments.store');
156160
Route::get('attachments/{attachment}/download', [AttachmentController::class, 'download'])->name('attachments.download');
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
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('batch_payments', function (Blueprint $table) {
12+
$table->id();
13+
$table->foreignId('tenant_id')->constrained()->cascadeOnDelete();
14+
$table->string('reference')->unique();
15+
$table->date('payment_date');
16+
$table->enum('payment_method', ['bank_transfer', 'cheque', 'cash', 'card', 'other'])->default('bank_transfer');
17+
$table->enum('type', ['received', 'made'])->default('received');
18+
$table->decimal('total_amount', 15, 2)->default(0);
19+
$table->text('notes')->nullable();
20+
$table->timestamps();
21+
$table->softDeletes();
22+
});
23+
}
24+
25+
public function down(): void
26+
{
27+
Schema::dropIfExists('batch_payments');
28+
}
29+
};
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
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::table('payments', function (Blueprint $table) {
12+
$table->foreignId('batch_payment_id')
13+
->nullable()
14+
->after('notes')
15+
->constrained('batch_payments')
16+
->nullOnDelete();
17+
});
18+
}
19+
20+
public function down(): void
21+
{
22+
Schema::table('payments', function (Blueprint $table) {
23+
$table->dropForeignIdFor(\App\Modules\Finance\Models\BatchPayment::class, 'batch_payment_id');
24+
$table->dropColumn('batch_payment_id');
25+
});
26+
}
27+
};

0 commit comments

Comments
 (0)