Skip to content

Commit 1a1f034

Browse files
committed
Phase 65: Batch Payment Processing API
- BatchPaymentApiController: create batch with multiple invoice payments, list/view/delete batches, summary of received vs made totals - Routes: /api/v1/batch-payments with /summary - Tests: 8 tests covering batch creation, total calculation, type filtering Co-Authored-By: Claude <noreply@anthropic.com>
1 parent d0640f1 commit 1a1f034

3 files changed

Lines changed: 310 additions & 0 deletions

File tree

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
<?php
2+
3+
namespace App\Http\Controllers\Api\V1;
4+
5+
use App\Modules\Finance\Models\BatchPayment;
6+
use App\Modules\Finance\Models\Invoice;
7+
use App\Modules\Finance\Models\Payment;
8+
use Illuminate\Http\JsonResponse;
9+
use Illuminate\Http\Request;
10+
use Illuminate\Support\Facades\DB;
11+
12+
class BatchPaymentApiController extends ApiController
13+
{
14+
public function index(Request $request): JsonResponse
15+
{
16+
$tenantId = $this->tenantId($request);
17+
$batches = BatchPayment::where('tenant_id', $tenantId)
18+
->withCount('payments')
19+
->when($request->input('type'), fn ($q, $t) => $q->where('type', $t))
20+
->orderByDesc('payment_date')
21+
->get();
22+
23+
return $this->success($batches);
24+
}
25+
26+
public function store(Request $request): JsonResponse
27+
{
28+
$tenantId = $this->tenantId($request);
29+
30+
$data = $request->validate([
31+
'payment_date' => ['required', 'date'],
32+
'payment_method' => ['required', 'in:bank_transfer,cheque,cash,card,other'],
33+
'type' => ['required', 'in:received,made'],
34+
'notes' => ['nullable', 'string'],
35+
'payments' => ['required', 'array', 'min:1'],
36+
'payments.*.invoice_id' => ['required', 'integer', 'exists:invoices,id'],
37+
'payments.*.amount' => ['required', 'numeric', 'min:0.01'],
38+
'payments.*.reference' => ['nullable', 'string', 'max:100'],
39+
]);
40+
41+
$totalAmount = collect($data['payments'])->sum('amount');
42+
$ref = 'BATCH-' . strtoupper(uniqid());
43+
44+
$batch = DB::transaction(function () use ($tenantId, $data, $totalAmount, $ref) {
45+
$batch = BatchPayment::create([
46+
'tenant_id' => $tenantId,
47+
'reference' => $ref,
48+
'payment_date' => $data['payment_date'],
49+
'payment_method' => $data['payment_method'],
50+
'type' => $data['type'],
51+
'total_amount' => $totalAmount,
52+
'notes' => $data['notes'] ?? null,
53+
]);
54+
55+
foreach ($data['payments'] as $paymentData) {
56+
Payment::create([
57+
'tenant_id' => $tenantId,
58+
'invoice_id' => $paymentData['invoice_id'],
59+
'batch_payment_id' => $batch->id,
60+
'amount' => $paymentData['amount'],
61+
'payment_date' => $data['payment_date'],
62+
'method' => $data['payment_method'],
63+
'reference' => $paymentData['reference'] ?? $ref,
64+
'notes' => $data['notes'] ?? null,
65+
]);
66+
}
67+
68+
return $batch;
69+
});
70+
71+
return $this->success($batch->load('payments.invoice:id,number,contact_id'), 201);
72+
}
73+
74+
public function show(BatchPayment $batchPayment): JsonResponse
75+
{
76+
$batchPayment->load('payments.invoice:id,number,contact_id');
77+
78+
return $this->success($batchPayment);
79+
}
80+
81+
public function destroy(BatchPayment $batchPayment): JsonResponse
82+
{
83+
DB::transaction(function () use ($batchPayment) {
84+
$batchPayment->payments()->delete();
85+
$batchPayment->delete();
86+
});
87+
88+
return $this->success(['message' => 'Batch payment deleted.']);
89+
}
90+
91+
public function summary(Request $request): JsonResponse
92+
{
93+
$tenantId = $this->tenantId($request);
94+
95+
$stats = BatchPayment::where('tenant_id', $tenantId)
96+
->selectRaw('type, COUNT(*) as batch_count, SUM(total_amount) as total')
97+
->groupBy('type')
98+
->get()
99+
->keyBy('type');
100+
101+
return $this->success([
102+
'total_received' => (float) ($stats['received']->total ?? 0),
103+
'total_made' => (float) ($stats['made']->total ?? 0),
104+
'batch_count_received' => (int) ($stats['received']->batch_count ?? 0),
105+
'batch_count_made' => (int) ($stats['made']->batch_count ?? 0),
106+
]);
107+
}
108+
109+
private function tenantId(Request $request): int
110+
{
111+
return app()->has('tenant') ? app('tenant')->id : $request->user()->tenant_id;
112+
}
113+
}

erp/routes/api.php

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -627,6 +627,15 @@
627627
Route::delete('/{tokenId}', [\App\Http\Controllers\Api\V1\ApiTokenController::class, 'destroy']);
628628
});
629629

630+
// Batch Payments
631+
Route::prefix('batch-payments')->group(function () {
632+
Route::get('/summary', [\App\Http\Controllers\Api\V1\BatchPaymentApiController::class, 'summary']);
633+
Route::get('/', [\App\Http\Controllers\Api\V1\BatchPaymentApiController::class, 'index']);
634+
Route::post('/', [\App\Http\Controllers\Api\V1\BatchPaymentApiController::class, 'store']);
635+
Route::get('/{batchPayment}', [\App\Http\Controllers\Api\V1\BatchPaymentApiController::class, 'show']);
636+
Route::delete('/{batchPayment}', [\App\Http\Controllers\Api\V1\BatchPaymentApiController::class, 'destroy']);
637+
});
638+
630639
// Quotations / Proposals
631640
Route::prefix('quotes')->group(function () {
632641
Route::get('/', [\App\Http\Controllers\Api\V1\QuoteApiController::class, 'index']);
Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,188 @@
1+
<?php
2+
3+
use App\Models\User;
4+
use App\Modules\Core\Models\Tenant;
5+
use App\Modules\Finance\Models\BatchPayment;
6+
use App\Modules\Finance\Models\Contact;
7+
use App\Modules\Finance\Models\Invoice;
8+
use App\Modules\Finance\Models\Payment;
9+
use Database\Seeders\RolePermissionSeeder;
10+
11+
beforeEach(function () {
12+
$this->seed(RolePermissionSeeder::class);
13+
$this->tenant = Tenant::create(['name' => 'Pay Co', 'slug' => 'pay-co-' . uniqid()]);
14+
$this->user = User::factory()->create(['tenant_id' => $this->tenant->id]);
15+
$this->user->assignRole('super-admin');
16+
$this->token = $this->user->createToken('test')->plainTextToken;
17+
app()->instance('tenant', $this->tenant);
18+
});
19+
20+
function makeBatchInvoice(): Invoice
21+
{
22+
$contact = Contact::create([
23+
'tenant_id' => test()->tenant->id,
24+
'name' => 'Batch Client ' . uniqid(),
25+
'type' => 'customer',
26+
]);
27+
28+
return Invoice::create([
29+
'tenant_id' => test()->tenant->id,
30+
'contact_id' => $contact->id,
31+
'issue_date' => now()->toDateString(),
32+
'due_date' => now()->addDays(30)->toDateString(),
33+
'status' => 'sent',
34+
]);
35+
}
36+
37+
test('can create a batch payment', function () {
38+
$inv1 = makeBatchInvoice();
39+
$inv2 = makeBatchInvoice();
40+
41+
$this->withToken($this->token)
42+
->postJson('/api/v1/batch-payments', [
43+
'payment_date' => now()->toDateString(),
44+
'payment_method' => 'bank_transfer',
45+
'type' => 'received',
46+
'payments' => [
47+
['invoice_id' => $inv1->id, 'amount' => 500.00],
48+
['invoice_id' => $inv2->id, 'amount' => 750.00],
49+
],
50+
])
51+
->assertStatus(201)
52+
->assertJsonPath('data.type', 'received')
53+
->assertJsonStructure(['data' => ['reference', 'total_amount', 'payments']]);
54+
55+
expect(Payment::where('invoice_id', $inv1->id)->exists())->toBeTrue();
56+
expect(Payment::where('invoice_id', $inv2->id)->exists())->toBeTrue();
57+
});
58+
59+
test('total_amount is sum of individual payments', function () {
60+
$inv1 = makeBatchInvoice();
61+
$inv2 = makeBatchInvoice();
62+
63+
$response = $this->withToken($this->token)
64+
->postJson('/api/v1/batch-payments', [
65+
'payment_date' => now()->toDateString(),
66+
'payment_method' => 'cash',
67+
'type' => 'received',
68+
'payments' => [
69+
['invoice_id' => $inv1->id, 'amount' => 200.00],
70+
['invoice_id' => $inv2->id, 'amount' => 300.00],
71+
],
72+
])
73+
->assertStatus(201);
74+
75+
expect((float) $response->json('data.total_amount'))->toBe(500.0);
76+
});
77+
78+
test('can list batch payments', function () {
79+
$inv = makeBatchInvoice();
80+
BatchPayment::create([
81+
'tenant_id' => $this->tenant->id,
82+
'reference' => 'BATCH-001',
83+
'payment_date' => now()->toDateString(),
84+
'payment_method' => 'bank_transfer',
85+
'type' => 'received',
86+
'total_amount' => 1000.00,
87+
]);
88+
89+
$response = $this->withToken($this->token)
90+
->getJson('/api/v1/batch-payments')
91+
->assertStatus(200);
92+
93+
expect(count($response->json('data')))->toBeGreaterThanOrEqual(1);
94+
});
95+
96+
test('can view a batch payment with individual payments', function () {
97+
$inv = makeBatchInvoice();
98+
99+
$batch = BatchPayment::create([
100+
'tenant_id' => $this->tenant->id,
101+
'reference' => 'BATCH-002',
102+
'payment_date' => now()->toDateString(),
103+
'payment_method' => 'cheque',
104+
'type' => 'made',
105+
'total_amount' => 500.00,
106+
]);
107+
108+
Payment::create([
109+
'tenant_id' => $this->tenant->id,
110+
'invoice_id' => $inv->id,
111+
'batch_payment_id' => $batch->id,
112+
'amount' => 500.00,
113+
'payment_date' => now()->toDateString(),
114+
'method' => 'cheque',
115+
]);
116+
117+
$this->withToken($this->token)
118+
->getJson("/api/v1/batch-payments/{$batch->id}")
119+
->assertStatus(200)
120+
->assertJsonStructure(['data' => ['reference', 'payments']]);
121+
});
122+
123+
test('can filter by type', function () {
124+
BatchPayment::create([
125+
'tenant_id' => $this->tenant->id,
126+
'reference' => 'BATCH-REC',
127+
'payment_date' => now()->toDateString(),
128+
'payment_method' => 'cash',
129+
'type' => 'received',
130+
'total_amount' => 100.00,
131+
]);
132+
133+
BatchPayment::create([
134+
'tenant_id' => $this->tenant->id,
135+
'reference' => 'BATCH-MADE',
136+
'payment_date' => now()->toDateString(),
137+
'payment_method' => 'cash',
138+
'type' => 'made',
139+
'total_amount' => 200.00,
140+
]);
141+
142+
$response = $this->withToken($this->token)
143+
->getJson('/api/v1/batch-payments?type=received')
144+
->assertStatus(200);
145+
146+
foreach ($response->json('data') as $item) {
147+
expect($item['type'])->toBe('received');
148+
}
149+
});
150+
151+
test('can get batch payment summary', function () {
152+
BatchPayment::create([
153+
'tenant_id' => $this->tenant->id,
154+
'reference' => 'BATCH-SUM-1',
155+
'payment_date' => now()->toDateString(),
156+
'payment_method' => 'bank_transfer',
157+
'type' => 'received',
158+
'total_amount' => 1000.00,
159+
]);
160+
161+
$response = $this->withToken($this->token)
162+
->getJson('/api/v1/batch-payments/summary')
163+
->assertStatus(200)
164+
->assertJsonStructure(['data' => ['total_received', 'total_made', 'batch_count_received']]);
165+
166+
expect((float) $response->json('data.total_received'))->toBeGreaterThanOrEqual(1000.0);
167+
});
168+
169+
test('can delete a batch payment', function () {
170+
$batch = BatchPayment::create([
171+
'tenant_id' => $this->tenant->id,
172+
'reference' => 'BATCH-DEL',
173+
'payment_date' => now()->toDateString(),
174+
'payment_method' => 'cash',
175+
'type' => 'received',
176+
'total_amount' => 100.00,
177+
]);
178+
179+
$this->withToken($this->token)
180+
->deleteJson("/api/v1/batch-payments/{$batch->id}")
181+
->assertStatus(200);
182+
183+
expect(BatchPayment::withTrashed()->find($batch->id)?->deleted_at)->not->toBeNull();
184+
});
185+
186+
test('requires authentication', function () {
187+
$this->getJson('/api/v1/batch-payments')->assertStatus(401);
188+
});

0 commit comments

Comments
 (0)