Skip to content

Commit f844202

Browse files
committed
feat(phase-35): customer credit limits with per-contact enforcement
- Migration adds credit_limit, credit_terms_days, credit_hold to contacts - CreditLimitService: outstanding balance via invoice_items JOIN, availability check - CreditLimitController: GET/PUT credit status, credit check endpoint, alerts list - Routes: /contacts/{contact}/credit (GET/PUT), /contacts/{contact}/credit/check, /credit-alerts - 10 feature tests covering limit enforcement, holds, alerts, and service logic Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RdUGwo74JXChRCF88Yu27d
1 parent fa8a575 commit f844202

7 files changed

Lines changed: 378 additions & 1 deletion

File tree

CLAUDE.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,7 @@ beforeEach(function () {
166166
| 32 | Financial Forecasting — revenue + cash-flow projections ||
167167
| 33 | Smart Alert Rules — threshold monitoring + notifications ||
168168
| 34 | Budget Management REST API — CRUD + activate + variance ||
169+
| 35 | Customer Credit Limits — per-contact limits, hold, check API ||
169170

170171
## File Locations Reference
171172

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
<?php
2+
3+
namespace App\Http\Controllers\Api\V1;
4+
5+
use App\Modules\Finance\Models\Contact;
6+
use App\Services\CreditLimitService;
7+
use Illuminate\Http\JsonResponse;
8+
use Illuminate\Http\Request;
9+
10+
class CreditLimitController extends ApiController
11+
{
12+
public function __construct(private CreditLimitService $service) {}
13+
14+
public function show(Request $request, Contact $contact): JsonResponse
15+
{
16+
return $this->success($this->service->getCreditStatus($contact));
17+
}
18+
19+
public function update(Request $request, Contact $contact): JsonResponse
20+
{
21+
$data = $request->validate([
22+
'credit_limit' => ['sometimes', 'numeric', 'min:0'],
23+
'credit_terms_days' => ['sometimes', 'integer', 'min:0', 'max:365'],
24+
'credit_hold' => ['sometimes', 'boolean'],
25+
]);
26+
27+
$contact->update($data);
28+
29+
return $this->success($this->service->getCreditStatus($contact->fresh()));
30+
}
31+
32+
public function check(Request $request, Contact $contact): JsonResponse
33+
{
34+
$data = $request->validate([
35+
'amount' => ['required', 'numeric', 'min:0'],
36+
]);
37+
38+
$would = $this->service->wouldExceedLimit($contact, $data['amount']);
39+
$status = $this->service->getCreditStatus($contact);
40+
$onHold = $contact->credit_hold;
41+
42+
return $this->success([
43+
'contact_id' => $contact->id,
44+
'amount_requested' => $data['amount'],
45+
'would_exceed' => $would,
46+
'on_hold' => $onHold,
47+
'approved' => ! $would && ! $onHold,
48+
'credit_status' => $status,
49+
]);
50+
}
51+
52+
public function alerts(Request $request): JsonResponse
53+
{
54+
$tenantId = app()->has('tenant') ? app('tenant')->id : $request->user()->tenant_id;
55+
$threshold = (float) $request->get('threshold', 80);
56+
57+
$alerts = $this->service->getContactsNearLimit($tenantId, $threshold);
58+
59+
return $this->success($alerts);
60+
}
61+
}

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

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,9 +21,15 @@ class Contact extends Model
2121
protected $fillable = [
2222
'tenant_id', 'name', 'email', 'phone',
2323
'address', 'type', 'price_list_id', 'notes', 'is_active',
24+
'credit_limit', 'credit_terms_days', 'credit_hold',
2425
];
2526

26-
protected $casts = ['is_active' => 'boolean'];
27+
protected $casts = [
28+
'is_active' => 'boolean',
29+
'credit_hold' => 'boolean',
30+
'credit_limit' => 'decimal:2',
31+
'credit_terms_days' => 'integer',
32+
];
2733

2834
public function invoices(): HasMany
2935
{
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
<?php
2+
3+
namespace App\Services;
4+
5+
use App\Modules\Finance\Models\Contact;
6+
use App\Modules\Finance\Models\Invoice;
7+
use Illuminate\Support\Facades\DB;
8+
9+
class CreditLimitService
10+
{
11+
public function getOutstandingBalance(Contact $contact): float
12+
{
13+
return (float) DB::table('invoice_items')
14+
->join('invoices', 'invoices.id', '=', 'invoice_items.invoice_id')
15+
->where('invoices.contact_id', $contact->id)
16+
->whereIn('invoices.status', ['sent', 'partial'])
17+
->whereNull('invoices.deleted_at')
18+
->sum(DB::raw('invoice_items.quantity * invoice_items.unit_price'));
19+
}
20+
21+
public function getAvailableCredit(Contact $contact): float
22+
{
23+
if ($contact->credit_limit <= 0) {
24+
return PHP_FLOAT_MAX;
25+
}
26+
27+
$outstanding = $this->getOutstandingBalance($contact);
28+
return max(0, $contact->credit_limit - $outstanding);
29+
}
30+
31+
public function wouldExceedLimit(Contact $contact, float $amount): bool
32+
{
33+
if ($contact->credit_limit <= 0) {
34+
return false;
35+
}
36+
37+
$outstanding = $this->getOutstandingBalance($contact);
38+
return ($outstanding + $amount) > $contact->credit_limit;
39+
}
40+
41+
public function getCreditStatus(Contact $contact): array
42+
{
43+
$outstanding = $this->getOutstandingBalance($contact);
44+
$available = $this->getAvailableCredit($contact);
45+
$limitSet = $contact->credit_limit > 0;
46+
$utilizationPct = $limitSet
47+
? min(100, round(($outstanding / $contact->credit_limit) * 100, 2))
48+
: 0;
49+
50+
return [
51+
'contact_id' => $contact->id,
52+
'contact_name' => $contact->name,
53+
'credit_limit' => $contact->credit_limit,
54+
'credit_terms_days' => $contact->credit_terms_days,
55+
'credit_hold' => $contact->credit_hold,
56+
'outstanding_balance' => round($outstanding, 2),
57+
'available_credit' => $limitSet ? round($available, 2) : null,
58+
'utilization_percent' => $utilizationPct,
59+
'is_over_limit' => $limitSet && $outstanding > $contact->credit_limit,
60+
'limit_set' => $limitSet,
61+
];
62+
}
63+
64+
public function getContactsNearLimit(int $tenantId, float $threshold = 80.0): array
65+
{
66+
$contacts = Contact::where('tenant_id', $tenantId)
67+
->customers()
68+
->where('credit_limit', '>', 0)
69+
->get();
70+
71+
$alerts = [];
72+
foreach ($contacts as $contact) {
73+
$status = $this->getCreditStatus($contact);
74+
if ($status['utilization_percent'] >= $threshold || $contact->credit_hold) {
75+
$alerts[] = $status;
76+
}
77+
}
78+
79+
usort($alerts, fn ($a, $b) => $b['utilization_percent'] <=> $a['utilization_percent']);
80+
return $alerts;
81+
}
82+
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
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('contacts', function (Blueprint $table) {
12+
$table->decimal('credit_limit', 15, 2)->default(0)->after('is_active');
13+
$table->integer('credit_terms_days')->default(30)->after('credit_limit');
14+
$table->boolean('credit_hold')->default(false)->after('credit_terms_days');
15+
});
16+
}
17+
18+
public function down(): void
19+
{
20+
Schema::table('contacts', function (Blueprint $table) {
21+
$table->dropColumn(['credit_limit', 'credit_terms_days', 'credit_hold']);
22+
});
23+
}
24+
};

erp/routes/api.php

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -426,5 +426,11 @@
426426
Route::delete('/{reportSchedule}', [\App\Http\Controllers\Api\V1\ReportScheduleController::class, 'destroy']);
427427
Route::post('/{reportSchedule}/send', [\App\Http\Controllers\Api\V1\ReportScheduleController::class, 'sendNow']);
428428
});
429+
430+
// Customer Credit Limits
431+
Route::get('/credit-alerts', [\App\Http\Controllers\Api\V1\CreditLimitController::class, 'alerts']);
432+
Route::get('/contacts/{contact}/credit', [\App\Http\Controllers\Api\V1\CreditLimitController::class, 'show']);
433+
Route::put('/contacts/{contact}/credit', [\App\Http\Controllers\Api\V1\CreditLimitController::class, 'update']);
434+
Route::post('/contacts/{contact}/credit/check', [\App\Http\Controllers\Api\V1\CreditLimitController::class, 'check']);
429435
});
430436
});

0 commit comments

Comments
 (0)