Skip to content

Commit 2bf70bb

Browse files
committed
feat(phase-32): financial forecasting api with revenue and cash-flow projections
- ForecastController: revenue + cash-flow forecasts based on trailing 12-month history - Linear regression trend: calculates slope of historical monthly values - Revenue forecast: paid invoices, 15% confidence interval (lower/upper bounds) - Cash-flow forecast: inflows (paid invoices) vs outflows (bills), net projected per month - GET /api/v1/forecast/revenue?months=6 — configurable window, capped at 24 months - GET /api/v1/forecast/cash-flow?months=6 — inflow/outflow/net per projected month - 7 feature tests: structure, months param, cap at 24, bounds, historical baseline, auth Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RdUGwo74JXChRCF88Yu27d
1 parent 0b216f7 commit 2bf70bb

3 files changed

Lines changed: 232 additions & 0 deletions

File tree

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
<?php
2+
3+
namespace App\Http\Controllers\Api\V1;
4+
5+
use Illuminate\Http\JsonResponse;
6+
use Illuminate\Http\Request;
7+
use Illuminate\Support\Carbon;
8+
use Illuminate\Support\Facades\DB;
9+
10+
class ForecastController extends ApiController
11+
{
12+
public function revenue(Request $request): JsonResponse
13+
{
14+
$tenantId = app()->has('tenant') ? app('tenant')->id : $request->user()->tenant_id;
15+
$months = $request->integer('months', 6);
16+
$months = min(max($months, 1), 24);
17+
18+
// Historical monthly revenue from the past 12 months
19+
$historical = DB::table('invoices')
20+
->join('invoice_items', 'invoices.id', '=', 'invoice_items.invoice_id')
21+
->where('invoices.tenant_id', $tenantId)
22+
->where('invoices.status', 'paid')
23+
->where('invoices.created_at', '>=', now()->subYear())
24+
->selectRaw("strftime('%Y-%m', invoices.created_at) as month, SUM(invoice_items.quantity * invoice_items.unit_price) as revenue")
25+
->groupBy('month')
26+
->orderBy('month')
27+
->get();
28+
29+
$avgMonthly = $historical->isNotEmpty() ? $historical->avg('revenue') : 0;
30+
$trend = $this->calculateTrend($historical->pluck('revenue')->toArray());
31+
32+
$forecast = [];
33+
for ($i = 1; $i <= $months; $i++) {
34+
$date = now()->addMonths($i);
35+
$projected = max(0, $avgMonthly + ($trend * $i));
36+
$forecast[] = [
37+
'month' => $date->format('Y-m'),
38+
'label' => $date->format('M Y'),
39+
'projected' => round($projected, 2),
40+
'lower' => round($projected * 0.85, 2),
41+
'upper' => round($projected * 1.15, 2),
42+
];
43+
}
44+
45+
return $this->success([
46+
'historical' => $historical,
47+
'avg_monthly' => round($avgMonthly, 2),
48+
'trend_per_month' => round($trend, 2),
49+
'forecast' => $forecast,
50+
]);
51+
}
52+
53+
public function cashFlow(Request $request): JsonResponse
54+
{
55+
$tenantId = app()->has('tenant') ? app('tenant')->id : $request->user()->tenant_id;
56+
$months = $request->integer('months', 6);
57+
$months = min(max($months, 1), 24);
58+
59+
// Historical monthly inflows (paid invoices)
60+
$inflows = DB::table('invoices')
61+
->join('invoice_items', 'invoices.id', '=', 'invoice_items.invoice_id')
62+
->where('invoices.tenant_id', $tenantId)
63+
->where('invoices.status', 'paid')
64+
->where('invoices.created_at', '>=', now()->subYear())
65+
->selectRaw("strftime('%Y-%m', invoices.created_at) as month, SUM(invoice_items.quantity * invoice_items.unit_price) as amount")
66+
->groupBy('month')->orderBy('month')->get()->keyBy('month');
67+
68+
// Historical monthly outflows (bills)
69+
$outflows = DB::table('bills')
70+
->join('bill_items', 'bills.id', '=', 'bill_items.bill_id')
71+
->where('bills.tenant_id', $tenantId)
72+
->where('bills.created_at', '>=', now()->subYear())
73+
->whereNull('bills.deleted_at')
74+
->selectRaw("strftime('%Y-%m', bills.created_at) as month, SUM(bill_items.quantity * bill_items.unit_price) as amount")
75+
->groupBy('month')->orderBy('month')->get()->keyBy('month');
76+
77+
$avgInflow = $inflows->isNotEmpty() ? $inflows->avg('amount') : 0;
78+
$avgOutflow = $outflows->isNotEmpty() ? $outflows->avg('amount') : 0;
79+
$inTrend = $this->calculateTrend($inflows->pluck('amount')->toArray());
80+
$outTrend = $this->calculateTrend($outflows->pluck('amount')->toArray());
81+
82+
$forecast = [];
83+
for ($i = 1; $i <= $months; $i++) {
84+
$date = now()->addMonths($i);
85+
$projectedIn = max(0, $avgInflow + ($inTrend * $i));
86+
$projectedOut = max(0, $avgOutflow + ($outTrend * $i));
87+
$forecast[] = [
88+
'month' => $date->format('Y-m'),
89+
'label' => $date->format('M Y'),
90+
'projected_in' => round($projectedIn, 2),
91+
'projected_out' => round($projectedOut, 2),
92+
'projected_net' => round($projectedIn - $projectedOut, 2),
93+
];
94+
}
95+
96+
return $this->success([
97+
'avg_monthly_inflow' => round($avgInflow, 2),
98+
'avg_monthly_outflow' => round($avgOutflow, 2),
99+
'forecast' => $forecast,
100+
]);
101+
}
102+
103+
/**
104+
* Simple linear regression slope: how much does revenue change per month on average.
105+
*
106+
* @param float[] $values
107+
*/
108+
private function calculateTrend(array $values): float
109+
{
110+
$n = count($values);
111+
if ($n < 2) {
112+
return 0;
113+
}
114+
115+
$xBar = ($n - 1) / 2;
116+
$yBar = array_sum($values) / $n;
117+
118+
$numerator = 0;
119+
$denominator = 0;
120+
121+
foreach ($values as $x => $y) {
122+
$numerator += ($x - $xBar) * ($y - $yBar);
123+
$denominator += ($x - $xBar) ** 2;
124+
}
125+
126+
return $denominator == 0 ? 0 : $numerator / $denominator;
127+
}
128+
}

erp/routes/api.php

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -346,6 +346,12 @@
346346
Route::post('/mark-all-read', [\App\Http\Controllers\Api\V1\NotificationController::class, 'markAllRead']);
347347
});
348348

349+
// Financial Forecasting
350+
Route::prefix('forecast')->group(function () {
351+
Route::get('/revenue', [\App\Http\Controllers\Api\V1\ForecastController::class, 'revenue']);
352+
Route::get('/cash-flow', [\App\Http\Controllers\Api\V1\ForecastController::class, 'cashFlow']);
353+
});
354+
349355
// Reports
350356
Route::prefix('reports')->group(function () {
351357
Route::get('/financial', [\App\Http\Controllers\Api\V1\ReportsController::class, 'financial']);
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
<?php
2+
3+
use App\Models\User;
4+
use App\Modules\Core\Models\Tenant;
5+
use App\Modules\Finance\Models\Contact;
6+
use App\Modules\Finance\Models\Invoice;
7+
use App\Modules\Finance\Models\InvoiceItem;
8+
use Database\Seeders\RolePermissionSeeder;
9+
10+
beforeEach(function () {
11+
$this->seed(RolePermissionSeeder::class);
12+
$this->tenant = Tenant::create(['name' => 'Forecast Co', 'slug' => 'forecast-co-' . uniqid()]);
13+
$this->user = User::factory()->create(['tenant_id' => $this->tenant->id]);
14+
$this->user->assignRole('super-admin');
15+
$this->token = $this->user->createToken('test')->plainTextToken;
16+
app()->instance('tenant', $this->tenant);
17+
});
18+
19+
test('revenue forecast returns historical and projected data', function () {
20+
$response = $this->withToken($this->token)->getJson('/api/v1/forecast/revenue');
21+
$response->assertStatus(200);
22+
23+
$data = $response->json('data');
24+
expect($data)->toHaveKeys(['historical', 'avg_monthly', 'trend_per_month', 'forecast']);
25+
expect($data['forecast'])->toHaveCount(6); // default 6 months
26+
});
27+
28+
test('revenue forecast respects months parameter', function () {
29+
$response = $this->withToken($this->token)->getJson('/api/v1/forecast/revenue?months=3');
30+
$response->assertStatus(200);
31+
expect($response->json('data.forecast'))->toHaveCount(3);
32+
});
33+
34+
test('revenue forecast caps months at 24', function () {
35+
$response = $this->withToken($this->token)->getJson('/api/v1/forecast/revenue?months=100');
36+
$response->assertStatus(200);
37+
expect($response->json('data.forecast'))->toHaveCount(24);
38+
});
39+
40+
test('revenue forecast includes lower and upper bounds', function () {
41+
$response = $this->withToken($this->token)->getJson('/api/v1/forecast/revenue?months=1');
42+
$response->assertStatus(200);
43+
44+
$point = $response->json('data.forecast.0');
45+
expect($point)->toHaveKeys(['month', 'label', 'projected', 'lower', 'upper']);
46+
expect($point['lower'])->toBeLessThanOrEqual($point['projected']);
47+
expect($point['upper'])->toBeGreaterThanOrEqual($point['projected']);
48+
});
49+
50+
test('revenue forecast uses historical paid invoices for baseline', function () {
51+
$contact = Contact::create([
52+
'tenant_id' => $this->tenant->id,
53+
'name' => 'Forecast Customer',
54+
'type' => 'customer',
55+
]);
56+
57+
$invoice = Invoice::create([
58+
'tenant_id' => $this->tenant->id,
59+
'contact_id' => $contact->id,
60+
'number' => 'FRC-001',
61+
'issue_date' => now()->subMonth(),
62+
'due_date' => now()->subMonth()->addDays(30),
63+
'status' => 'paid',
64+
'subtotal' => 1000,
65+
'tax' => 0,
66+
'total' => 1000,
67+
]);
68+
69+
InvoiceItem::create([
70+
'invoice_id' => $invoice->id,
71+
'description' => 'Service',
72+
'quantity' => 10,
73+
'unit_price' => 100,
74+
'tax_rate' => 0,
75+
]);
76+
77+
$response = $this->withToken($this->token)->getJson('/api/v1/forecast/revenue');
78+
$response->assertStatus(200);
79+
80+
expect($response->json('data.avg_monthly'))->toBeGreaterThan(0);
81+
expect($response->json('data.historical'))->not->toBeEmpty();
82+
});
83+
84+
test('cash flow forecast returns inflow and outflow projections', function () {
85+
$response = $this->withToken($this->token)->getJson('/api/v1/forecast/cash-flow');
86+
$response->assertStatus(200);
87+
88+
$data = $response->json('data');
89+
expect($data)->toHaveKeys(['avg_monthly_inflow', 'avg_monthly_outflow', 'forecast']);
90+
91+
$point = $data['forecast'][0];
92+
expect($point)->toHaveKeys(['month', 'label', 'projected_in', 'projected_out', 'projected_net']);
93+
});
94+
95+
test('requires authentication', function () {
96+
$this->getJson('/api/v1/forecast/revenue')->assertStatus(401);
97+
$this->getJson('/api/v1/forecast/cash-flow')->assertStatus(401);
98+
});

0 commit comments

Comments
 (0)