mirror of
https://github.com/invoiceninja/invoiceninja.git
synced 2026-04-18 12:10:50 +00:00
ChartController for analytics
This commit is contained in:
@@ -14,7 +14,9 @@ namespace App\Http\Controllers;
|
||||
|
||||
use App\Services\Chart\ChartService;
|
||||
use App\Http\Requests\Chart\ShowChartRequest;
|
||||
use App\Http\Requests\Chart\ShowForecastRequest;
|
||||
use App\Http\Requests\Chart\ShowCalculatedFieldRequest;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
|
||||
class ChartController extends BaseController
|
||||
{
|
||||
@@ -81,9 +83,16 @@ class ChartController extends BaseController
|
||||
$user = auth()->user();
|
||||
$admin_equivalent_permissions = $user->isAdmin() || $user->hasExactPermissionAndAll('view_all') || $user->hasExactPermissionAndAll('edit_all');
|
||||
|
||||
$cs = new ChartService($user->company(), $user, $admin_equivalent_permissions);
|
||||
$start = $request->input('start_date');
|
||||
$end = $request->input('end_date');
|
||||
$cacheKey = "analytics_summary:{$user->company()->id}:{$user->id}:{$start}:{$end}";
|
||||
|
||||
return response()->json($cs->analytics_summary($request->input('start_date'), $request->input('end_date')), 200);
|
||||
$data = Cache::remember($cacheKey, now()->addMinutes(15), function () use ($user, $admin_equivalent_permissions, $start, $end) {
|
||||
$cs = new ChartService($user->company(), $user, $admin_equivalent_permissions);
|
||||
return $cs->analytics_summary($start, $end);
|
||||
});
|
||||
|
||||
return response()->json($data, 200);
|
||||
}
|
||||
|
||||
public function analytics_totals(ShowChartRequest $request)
|
||||
@@ -92,9 +101,51 @@ class ChartController extends BaseController
|
||||
$user = auth()->user();
|
||||
$admin_equivalent_permissions = $user->isAdmin() || $user->hasExactPermissionAndAll('view_all') || $user->hasExactPermissionAndAll('edit_all');
|
||||
|
||||
$cs = new ChartService($user->company(), $user, $admin_equivalent_permissions);
|
||||
$start = $request->input('start_date');
|
||||
$end = $request->input('end_date');
|
||||
$cacheKey = "analytics_totals:{$user->company()->id}:{$user->id}:{$start}:{$end}";
|
||||
|
||||
return response()->json($cs->analytics_totals($request->input('start_date'), $request->input('end_date')), 200);
|
||||
$data = Cache::remember($cacheKey, now()->addMinutes(15), function () use ($user, $admin_equivalent_permissions, $start, $end) {
|
||||
$cs = new ChartService($user->company(), $user, $admin_equivalent_permissions);
|
||||
return $cs->analytics_totals($start, $end);
|
||||
});
|
||||
|
||||
return response()->json($data, 200);
|
||||
}
|
||||
|
||||
public function cashflow_forecast(ShowForecastRequest $request)
|
||||
{
|
||||
/** @var \App\Models\User auth()->user() */
|
||||
$user = auth()->user();
|
||||
$admin_equivalent_permissions = $user->isAdmin() || $user->hasExactPermissionAndAll('view_all') || $user->hasExactPermissionAndAll('edit_all');
|
||||
|
||||
$start = $request->input('start_date');
|
||||
$end = $request->input('end_date');
|
||||
$bucket = $request->input('bucket_type', 'monthly');
|
||||
$cacheKey = "cashflow_forecast:{$user->company()->id}:{$user->id}:{$start}:{$end}:{$bucket}";
|
||||
|
||||
$data = Cache::remember($cacheKey, now()->addMinutes(15), function () use ($user, $admin_equivalent_permissions, $start, $end, $bucket) {
|
||||
$cs = new ChartService($user->company(), $user, $admin_equivalent_permissions);
|
||||
return $cs->cashflow_forecast($start, $end, $bucket);
|
||||
});
|
||||
|
||||
return response()->json($data, 200);
|
||||
}
|
||||
|
||||
public function client_payment_analytics(ShowChartRequest $request)
|
||||
{
|
||||
/** @var \App\Models\User auth()->user() */
|
||||
$user = auth()->user();
|
||||
$admin_equivalent_permissions = $user->isAdmin() || $user->hasExactPermissionAndAll('view_all') || $user->hasExactPermissionAndAll('edit_all');
|
||||
|
||||
$cacheKey = "client_payment_analytics:{$user->company()->id}:{$user->id}";
|
||||
|
||||
$data = Cache::remember($cacheKey, now()->addMinutes(15), function () use ($user, $admin_equivalent_permissions) {
|
||||
$cs = new ChartService($user->company(), $user, $admin_equivalent_permissions);
|
||||
return $cs->client_payment_analytics();
|
||||
});
|
||||
|
||||
return response()->json($data, 200);
|
||||
}
|
||||
|
||||
public function calculatedFields(ShowCalculatedFieldRequest $request)
|
||||
|
||||
53
app/Http/Requests/Chart/ShowForecastRequest.php
Normal file
53
app/Http/Requests/Chart/ShowForecastRequest.php
Normal file
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Invoice Ninja (https://invoiceninja.com).
|
||||
*
|
||||
* @link https://github.com/invoiceninja/invoiceninja source repository
|
||||
*
|
||||
* @copyright Copyright (c) 2026. Invoice Ninja LLC (https://invoiceninja.com)
|
||||
*
|
||||
* @license https://www.elastic.co/licensing/elastic-license
|
||||
*/
|
||||
|
||||
namespace App\Http\Requests\Chart;
|
||||
|
||||
class ShowForecastRequest extends ShowChartRequest
|
||||
{
|
||||
public function rules()
|
||||
{
|
||||
return array_merge(parent::rules(), [
|
||||
'bucket_type' => 'bail|sometimes|string|in:weekly,monthly',
|
||||
]);
|
||||
}
|
||||
|
||||
public function prepareForValidation()
|
||||
{
|
||||
/** @var \App\Models\User auth()->user */
|
||||
$user = auth()->user();
|
||||
|
||||
$input = $this->all();
|
||||
|
||||
$input['include_drafts'] = filter_var($input['include_drafts'] ?? false, FILTER_VALIDATE_BOOLEAN);
|
||||
|
||||
if (isset($input['date_range'])) {
|
||||
$dates = $this->calculateStartAndEndDates($input, $user->company());
|
||||
$input['start_date'] = $dates[0];
|
||||
$input['end_date'] = $dates[1];
|
||||
}
|
||||
|
||||
if (! isset($input['start_date'])) {
|
||||
$input['start_date'] = now()->format('Y-m-d');
|
||||
}
|
||||
|
||||
if (! isset($input['end_date'])) {
|
||||
$input['end_date'] = now()->addMonths(6)->format('Y-m-d');
|
||||
}
|
||||
|
||||
if (! isset($input['bucket_type'])) {
|
||||
$input['bucket_type'] = 'monthly';
|
||||
}
|
||||
|
||||
$this->replace($input);
|
||||
}
|
||||
}
|
||||
@@ -143,7 +143,12 @@ class InvoicePay extends Component
|
||||
public function payableAmount($payable_amount)
|
||||
{
|
||||
$invite = \App\Models\InvoiceInvitation::withTrashed()->find($this->invitation_id);
|
||||
$this->setContext($invite->key, 'amount', $payable_amount);
|
||||
// $this->setContext($invite->key, 'amount', $payable_amount);
|
||||
|
||||
$this->bulkSetContext($invite->key, [
|
||||
'amount' => $payable_amount,
|
||||
'payment_processed' => null,
|
||||
]);
|
||||
$this->under_over_payment = false;
|
||||
}
|
||||
|
||||
@@ -158,6 +163,7 @@ class InvoicePay extends Component
|
||||
'amount' => $amount,
|
||||
'pre_payment' => false,
|
||||
'is_recurring' => false,
|
||||
'payment_processed' => null,
|
||||
]);
|
||||
|
||||
|
||||
|
||||
@@ -36,9 +36,18 @@ class ProcessPayment extends Component
|
||||
|
||||
MultiDB::setDb($this->getContext($this->_key)['db']);
|
||||
|
||||
$_context = $this->getContext($this->_key);
|
||||
|
||||
if (isset($_context['payment_processed'])) {
|
||||
$this->payment_view = $_context['payment_processed']['payment_view'];
|
||||
$this->payment_data_payload = $_context['payment_processed']['payment_data_payload'];
|
||||
$this->isLoading = false;
|
||||
return;
|
||||
}
|
||||
|
||||
$invitation = InvoiceInvitation::find($this->getContext($this->_key)['invitation_id']);
|
||||
|
||||
$_context = $this->getContext($this->_key);
|
||||
// $_context = $this->getContext($this->_key);
|
||||
|
||||
$data = [
|
||||
'company_gateway_id' => $_context['company_gateway_id'],
|
||||
@@ -91,6 +100,11 @@ class ProcessPayment extends Component
|
||||
);
|
||||
}
|
||||
|
||||
$this->setContext($this->_key, 'payment_processed', [
|
||||
'payment_view' => $this->payment_view,
|
||||
'payment_data_payload' => $this->payment_data_payload,
|
||||
]);
|
||||
|
||||
$this->isLoading = false;
|
||||
|
||||
}
|
||||
|
||||
412
app/Services/Chart/CashFlowForecastService.php
Normal file
412
app/Services/Chart/CashFlowForecastService.php
Normal file
@@ -0,0 +1,412 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Invoice Ninja (https://invoiceninja.com).
|
||||
*
|
||||
* @link https://github.com/invoiceninja/invoiceninja source repository
|
||||
*
|
||||
* @copyright Copyright (c) 2026. Invoice Ninja LLC (https://invoiceninja.com)
|
||||
*
|
||||
* @license https://www.elastic.co/licensing/elastic-license
|
||||
*/
|
||||
|
||||
namespace App\Services\Chart;
|
||||
|
||||
use App\Models\Company;
|
||||
use App\Models\RecurringInvoice;
|
||||
use Carbon\Carbon;
|
||||
|
||||
class CashFlowForecastService
|
||||
{
|
||||
private const MAX_DAILY_ITERATIONS = 365;
|
||||
|
||||
private const FREQUENCY_INTERVALS = [
|
||||
RecurringInvoice::FREQUENCY_DAILY => ['addDay', 1],
|
||||
RecurringInvoice::FREQUENCY_WEEKLY => ['addWeek', 1],
|
||||
RecurringInvoice::FREQUENCY_TWO_WEEKS => ['addWeeks', 2],
|
||||
RecurringInvoice::FREQUENCY_FOUR_WEEKS => ['addWeeks', 4],
|
||||
RecurringInvoice::FREQUENCY_MONTHLY => ['addMonthNoOverflow', 1],
|
||||
RecurringInvoice::FREQUENCY_TWO_MONTHS => ['addMonthsNoOverflow', 2],
|
||||
RecurringInvoice::FREQUENCY_THREE_MONTHS => ['addMonthsNoOverflow', 3],
|
||||
RecurringInvoice::FREQUENCY_FOUR_MONTHS => ['addMonthsNoOverflow', 4],
|
||||
RecurringInvoice::FREQUENCY_SIX_MONTHS => ['addMonthsNoOverflow', 6],
|
||||
RecurringInvoice::FREQUENCY_ANNUALLY => ['addYear', 1],
|
||||
RecurringInvoice::FREQUENCY_TWO_YEARS => ['addYears', 2],
|
||||
RecurringInvoice::FREQUENCY_THREE_YEARS => ['addYears', 3],
|
||||
];
|
||||
|
||||
private const WEIGHT_AUTO_BILL = 0.95;
|
||||
private const WEIGHT_NO_AUTO_BILL = 0.75;
|
||||
private const DEFAULT_PAYMENT_DAYS = 14;
|
||||
private const FALLBACK_CONFIDENCE_MULTIPLIER = 0.5;
|
||||
|
||||
/** @var array<string, array<string, mixed>> */
|
||||
private array $buckets = [];
|
||||
|
||||
public function __construct(
|
||||
private Company $company,
|
||||
private string $start_date,
|
||||
private string $end_date,
|
||||
private string $bucket_type = 'monthly',
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @param array<int, \stdClass> $outstandingInvoices
|
||||
* @param array<int, \stdClass> $recurringInvoices
|
||||
* @param array<int, \stdClass> $recurringExpenses
|
||||
* @param array<int, \stdClass> $upcomingExpenses
|
||||
* @param array<int, \stdClass> $openQuotes
|
||||
* @param array<int, \stdClass> $companyPaymentSummary
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function generate(
|
||||
array $outstandingInvoices,
|
||||
array $recurringInvoices,
|
||||
array $recurringExpenses,
|
||||
array $upcomingExpenses,
|
||||
array $openQuotes,
|
||||
array $companyPaymentSummary,
|
||||
): array {
|
||||
$this->buildBucketGrid();
|
||||
|
||||
$companyFallback = ! empty($companyPaymentSummary) ? $companyPaymentSummary[0] : null;
|
||||
|
||||
$this->projectOutstandingInvoices($outstandingInvoices, $companyFallback);
|
||||
$this->projectRecurringInvoices($recurringInvoices);
|
||||
$this->projectRecurringExpenses($recurringExpenses);
|
||||
$this->projectUpcomingExpenses($upcomingExpenses);
|
||||
$this->projectOpenQuotes($openQuotes);
|
||||
|
||||
return $this->buildResponse();
|
||||
}
|
||||
|
||||
private function buildBucketGrid(): void
|
||||
{
|
||||
$current = Carbon::parse($this->start_date);
|
||||
$end = Carbon::parse($this->end_date);
|
||||
|
||||
while ($current->lte($end)) {
|
||||
if ($this->bucket_type === 'weekly') {
|
||||
$key = $current->format('o-\\WW');
|
||||
$periodStart = $current->copy()->startOfWeek()->format('Y-m-d');
|
||||
$periodEnd = $current->copy()->endOfWeek()->format('Y-m-d');
|
||||
$current->addWeek();
|
||||
} else {
|
||||
$key = $current->format('Y-m');
|
||||
$periodStart = $current->copy()->startOfMonth()->format('Y-m-d');
|
||||
$periodEnd = $current->copy()->endOfMonth()->format('Y-m-d');
|
||||
$current->addMonthNoOverflow();
|
||||
}
|
||||
|
||||
$this->buckets[$key] = [
|
||||
'period' => $key,
|
||||
'period_start' => $periodStart,
|
||||
'period_end' => $periodEnd,
|
||||
'inflows' => [
|
||||
'outstanding_invoices' => ['amount' => 0.0, 'count' => 0, 'weighted_amount' => 0.0],
|
||||
'recurring_invoices' => ['amount' => 0.0, 'count' => 0, 'weighted_amount' => 0.0],
|
||||
'quote_pipeline' => ['amount' => 0.0, 'count' => 0, 'weighted_amount' => 0.0],
|
||||
'total' => 0.0,
|
||||
'weighted_total' => 0.0,
|
||||
],
|
||||
'outflows' => [
|
||||
'recurring_expenses' => ['amount' => 0.0, 'count' => 0],
|
||||
'one_off_expenses' => ['amount' => 0.0, 'count' => 0],
|
||||
'total' => 0.0,
|
||||
],
|
||||
'net' => 0.0,
|
||||
'weighted_net' => 0.0,
|
||||
'confidence' => 0.0,
|
||||
'_weights' => [],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
private function dateToBucketKey(string $date): ?string
|
||||
{
|
||||
$d = Carbon::parse($date);
|
||||
$start = Carbon::parse($this->start_date);
|
||||
$end = Carbon::parse($this->end_date);
|
||||
|
||||
if ($d->lt($start) || $d->gt($end)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($this->bucket_type === 'weekly') {
|
||||
return $d->format('o-\\WW');
|
||||
}
|
||||
|
||||
return $d->format('Y-m');
|
||||
}
|
||||
|
||||
private function normalizeToCompanyCurrency(float $amount, float $exchangeRate): float
|
||||
{
|
||||
$rate = ($exchangeRate == 0) ? 1.0 : $exchangeRate;
|
||||
|
||||
return $amount / $rate;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, \stdClass> $invoices
|
||||
*/
|
||||
private function projectOutstandingInvoices(array $invoices, ?\stdClass $companyFallback): void
|
||||
{
|
||||
$companyAvgDays = $companyFallback->avg_payment_days ?? self::DEFAULT_PAYMENT_DAYS;
|
||||
|
||||
foreach ($invoices as $invoice) {
|
||||
$avgDays = $invoice->client_avg_payment_days;
|
||||
$lateRatio = $invoice->client_late_ratio ?? 0;
|
||||
$dataPoints = $invoice->client_data_points ?? 0;
|
||||
|
||||
if ($avgDays === null) {
|
||||
$avgDays = $companyAvgDays;
|
||||
$weight = self::FALLBACK_CONFIDENCE_MULTIPLIER;
|
||||
} else {
|
||||
$weight = (1 - $lateRatio) * min($dataPoints / 30, 1.0);
|
||||
}
|
||||
|
||||
$expectedDate = Carbon::parse($invoice->invoice_date)->addDays((int) round($avgDays))->format('Y-m-d');
|
||||
$bucketKey = $this->dateToBucketKey($expectedDate);
|
||||
|
||||
if ($bucketKey === null || ! isset($this->buckets[$bucketKey])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$amount = $this->normalizeToCompanyCurrency((float) $invoice->balance, (float) ($invoice->exchange_rate ?? 1));
|
||||
|
||||
$this->buckets[$bucketKey]['inflows']['outstanding_invoices']['amount'] += $amount;
|
||||
$this->buckets[$bucketKey]['inflows']['outstanding_invoices']['count']++;
|
||||
$this->buckets[$bucketKey]['inflows']['outstanding_invoices']['weighted_amount'] += $amount * $weight;
|
||||
$this->buckets[$bucketKey]['_weights'][] = ['amount' => $amount, 'weight' => $weight];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, \stdClass> $recurringInvoices
|
||||
*/
|
||||
private function projectRecurringInvoices(array $recurringInvoices): void
|
||||
{
|
||||
foreach ($recurringInvoices as $ri) {
|
||||
$weight = $ri->auto_bill_enabled ? self::WEIGHT_AUTO_BILL : self::WEIGHT_NO_AUTO_BILL;
|
||||
$amount = $this->normalizeToCompanyCurrency((float) $ri->amount, (float) ($ri->exchange_rate ?? 1));
|
||||
$remainingCycles = (int) $ri->remaining_cycles;
|
||||
$frequencyId = (int) $ri->frequency_id;
|
||||
|
||||
$date = Carbon::parse($ri->next_send_date);
|
||||
$endDate = Carbon::parse($this->end_date);
|
||||
$iterations = 0;
|
||||
$maxIterations = ($frequencyId === RecurringInvoice::FREQUENCY_DAILY) ? self::MAX_DAILY_ITERATIONS : 1000;
|
||||
|
||||
while ($date->lte($endDate) && $iterations < $maxIterations) {
|
||||
if ($remainingCycles !== -1 && $iterations >= $remainingCycles) {
|
||||
break;
|
||||
}
|
||||
|
||||
$bucketKey = $this->dateToBucketKey($date->format('Y-m-d'));
|
||||
|
||||
if ($bucketKey !== null && isset($this->buckets[$bucketKey])) {
|
||||
$this->buckets[$bucketKey]['inflows']['recurring_invoices']['amount'] += $amount;
|
||||
$this->buckets[$bucketKey]['inflows']['recurring_invoices']['count']++;
|
||||
$this->buckets[$bucketKey]['inflows']['recurring_invoices']['weighted_amount'] += $amount * $weight;
|
||||
$this->buckets[$bucketKey]['_weights'][] = ['amount' => $amount, 'weight' => $weight];
|
||||
}
|
||||
|
||||
$date = self::advanceDateByFrequency($date, $frequencyId);
|
||||
|
||||
if ($date === null) {
|
||||
break;
|
||||
}
|
||||
|
||||
$iterations++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, \stdClass> $recurringExpenses
|
||||
*/
|
||||
private function projectRecurringExpenses(array $recurringExpenses): void
|
||||
{
|
||||
foreach ($recurringExpenses as $re) {
|
||||
$amount = $this->normalizeToCompanyCurrency(
|
||||
$this->computeExpenseWithTax($re),
|
||||
(float) ($re->exchange_rate ?? 1)
|
||||
);
|
||||
$remainingCycles = (int) $re->remaining_cycles;
|
||||
$frequencyId = (int) $re->frequency_id;
|
||||
|
||||
$date = Carbon::parse($re->next_send_date);
|
||||
$endDate = Carbon::parse($this->end_date);
|
||||
$iterations = 0;
|
||||
$maxIterations = ($frequencyId === RecurringInvoice::FREQUENCY_DAILY) ? self::MAX_DAILY_ITERATIONS : 1000;
|
||||
|
||||
while ($date->lte($endDate) && $iterations < $maxIterations) {
|
||||
if ($remainingCycles !== -1 && $iterations >= $remainingCycles) {
|
||||
break;
|
||||
}
|
||||
|
||||
$bucketKey = $this->dateToBucketKey($date->format('Y-m-d'));
|
||||
|
||||
if ($bucketKey !== null && isset($this->buckets[$bucketKey])) {
|
||||
$this->buckets[$bucketKey]['outflows']['recurring_expenses']['amount'] += $amount;
|
||||
$this->buckets[$bucketKey]['outflows']['recurring_expenses']['count']++;
|
||||
}
|
||||
|
||||
$date = self::advanceDateByFrequency($date, $frequencyId);
|
||||
|
||||
if ($date === null) {
|
||||
break;
|
||||
}
|
||||
|
||||
$iterations++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, \stdClass> $expenses
|
||||
*/
|
||||
private function projectUpcomingExpenses(array $expenses): void
|
||||
{
|
||||
foreach ($expenses as $expense) {
|
||||
$bucketKey = $this->dateToBucketKey($expense->date);
|
||||
|
||||
if ($bucketKey === null || ! isset($this->buckets[$bucketKey])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$amount = $this->normalizeToCompanyCurrency((float) $expense->amount, (float) ($expense->exchange_rate ?? 1));
|
||||
|
||||
$this->buckets[$bucketKey]['outflows']['one_off_expenses']['amount'] += $amount;
|
||||
$this->buckets[$bucketKey]['outflows']['one_off_expenses']['count']++;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, \stdClass> $quotes
|
||||
*/
|
||||
private function projectOpenQuotes(array $quotes): void
|
||||
{
|
||||
foreach ($quotes as $quote) {
|
||||
$conversionRate = (float) ($quote->client_conversion_rate ?? 0);
|
||||
$avgConversionDays = (float) ($quote->client_avg_conversion_days ?? 0);
|
||||
|
||||
if ($conversionRate <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$expectedDate = Carbon::parse($quote->date)->addDays((int) round($avgConversionDays))->format('Y-m-d');
|
||||
$bucketKey = $this->dateToBucketKey($expectedDate);
|
||||
|
||||
if ($bucketKey === null || ! isset($this->buckets[$bucketKey])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$amount = $this->normalizeToCompanyCurrency((float) $quote->amount, (float) ($quote->exchange_rate ?? 1));
|
||||
|
||||
$this->buckets[$bucketKey]['inflows']['quote_pipeline']['amount'] += $amount;
|
||||
$this->buckets[$bucketKey]['inflows']['quote_pipeline']['count']++;
|
||||
$this->buckets[$bucketKey]['inflows']['quote_pipeline']['weighted_amount'] += $amount * $conversionRate;
|
||||
$this->buckets[$bucketKey]['_weights'][] = ['amount' => $amount, 'weight' => $conversionRate];
|
||||
}
|
||||
}
|
||||
|
||||
private static function advanceDateByFrequency(Carbon $date, int $frequencyId): ?Carbon
|
||||
{
|
||||
if (! isset(self::FREQUENCY_INTERVALS[$frequencyId])) {
|
||||
return null;
|
||||
}
|
||||
|
||||
[$method, $value] = self::FREQUENCY_INTERVALS[$frequencyId];
|
||||
|
||||
return $date->copy()->{$method}($value);
|
||||
}
|
||||
|
||||
private function computeExpenseWithTax(\stdClass $expense): float
|
||||
{
|
||||
$amount = (float) $expense->amount;
|
||||
|
||||
if ($expense->uses_inclusive_taxes) {
|
||||
return $amount;
|
||||
}
|
||||
|
||||
$taxFromRates = $amount * (((float) ($expense->tax_rate1 ?? 0) + (float) ($expense->tax_rate2 ?? 0) + (float) ($expense->tax_rate3 ?? 0)) / 100);
|
||||
$fixedTax = (float) ($expense->tax_amount1 ?? 0) + (float) ($expense->tax_amount2 ?? 0) + (float) ($expense->tax_amount3 ?? 0);
|
||||
|
||||
return $amount + $taxFromRates + $fixedTax;
|
||||
}
|
||||
|
||||
private function calculateBucketConfidence(array $weights): float
|
||||
{
|
||||
if (empty($weights)) {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
$totalAmount = 0.0;
|
||||
$weightedSum = 0.0;
|
||||
|
||||
foreach ($weights as $w) {
|
||||
$totalAmount += abs($w['amount']);
|
||||
$weightedSum += abs($w['amount']) * $w['weight'];
|
||||
}
|
||||
|
||||
if ($totalAmount == 0) {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
return round($weightedSum / $totalAmount, 4);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function buildResponse(): array
|
||||
{
|
||||
$totalInflows = 0.0;
|
||||
$weightedInflows = 0.0;
|
||||
$totalOutflows = 0.0;
|
||||
|
||||
$resultBuckets = [];
|
||||
|
||||
foreach ($this->buckets as $key => &$bucket) {
|
||||
$inflowTotal = $bucket['inflows']['outstanding_invoices']['amount']
|
||||
+ $bucket['inflows']['recurring_invoices']['amount']
|
||||
+ $bucket['inflows']['quote_pipeline']['amount'];
|
||||
|
||||
$inflowWeighted = $bucket['inflows']['outstanding_invoices']['weighted_amount']
|
||||
+ $bucket['inflows']['recurring_invoices']['weighted_amount']
|
||||
+ $bucket['inflows']['quote_pipeline']['weighted_amount'];
|
||||
|
||||
$outflowTotal = $bucket['outflows']['recurring_expenses']['amount']
|
||||
+ $bucket['outflows']['one_off_expenses']['amount'];
|
||||
|
||||
$bucket['inflows']['total'] = round($inflowTotal, 2);
|
||||
$bucket['inflows']['weighted_total'] = round($inflowWeighted, 2);
|
||||
$bucket['outflows']['total'] = round($outflowTotal, 2);
|
||||
$bucket['net'] = round($inflowTotal - $outflowTotal, 2);
|
||||
$bucket['weighted_net'] = round($inflowWeighted - $outflowTotal, 2);
|
||||
$bucket['confidence'] = $this->calculateBucketConfidence($bucket['_weights']);
|
||||
|
||||
$totalInflows += $inflowTotal;
|
||||
$weightedInflows += $inflowWeighted;
|
||||
$totalOutflows += $outflowTotal;
|
||||
|
||||
unset($bucket['_weights']);
|
||||
$resultBuckets[] = $bucket;
|
||||
}
|
||||
|
||||
return [
|
||||
'start_date' => $this->start_date,
|
||||
'end_date' => $this->end_date,
|
||||
'bucket_type' => $this->bucket_type,
|
||||
'buckets' => $resultBuckets,
|
||||
'totals' => [
|
||||
'total_inflows' => round($totalInflows, 2),
|
||||
'weighted_inflows' => round($weightedInflows, 2),
|
||||
'total_outflows' => round($totalOutflows, 2),
|
||||
'net' => round($totalInflows - $totalOutflows, 2),
|
||||
'weighted_net' => round($weightedInflows - $totalOutflows, 2),
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,8 @@ use App\Models\Payment;
|
||||
use App\Models\Quote;
|
||||
use App\Models\Task;
|
||||
use App\Models\User;
|
||||
use App\Services\Chart\CashFlowForecastService;
|
||||
use App\Services\Chart\ClientPaymentAnalyticsService;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
|
||||
class ChartService
|
||||
@@ -276,6 +278,36 @@ class ChartService
|
||||
return $data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cash flow forecast — time-bucketed inflow/outflow projection.
|
||||
*/
|
||||
public function cashflow_forecast(string $start_date, string $end_date, string $bucket_type = 'monthly'): array
|
||||
{
|
||||
$forecast = new CashFlowForecastService($this->company, $start_date, $end_date, $bucket_type);
|
||||
|
||||
return $forecast->generate(
|
||||
$this->getOutstandingInvoicesForForecasting(),
|
||||
$this->getRecurringInvoiceProjections(),
|
||||
$this->getRecurringExpenseProjections(),
|
||||
$this->getUpcomingExpenses($start_date, $end_date),
|
||||
$this->getOpenQuotesForForecasting(),
|
||||
$this->getCompanyPaymentSummary()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Client payment analytics — scorecards with risk scoring.
|
||||
*/
|
||||
public function client_payment_analytics(): array
|
||||
{
|
||||
$analytics = new ClientPaymentAnalyticsService($this->company);
|
||||
|
||||
return $analytics->generate(
|
||||
$this->getClientPaymentSummary(null),
|
||||
$this->getCompanyPaymentSummary()
|
||||
);
|
||||
}
|
||||
|
||||
/* Analytics */
|
||||
|
||||
/////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
150
app/Services/Chart/ClientPaymentAnalyticsService.php
Normal file
150
app/Services/Chart/ClientPaymentAnalyticsService.php
Normal file
@@ -0,0 +1,150 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Invoice Ninja (https://invoiceninja.com).
|
||||
*
|
||||
* @link https://github.com/invoiceninja/invoiceninja source repository
|
||||
*
|
||||
* @copyright Copyright (c) 2026. Invoice Ninja LLC (https://invoiceninja.com)
|
||||
*
|
||||
* @license https://www.elastic.co/licensing/elastic-license
|
||||
*/
|
||||
|
||||
namespace App\Services\Chart;
|
||||
|
||||
use App\Models\Company;
|
||||
|
||||
class ClientPaymentAnalyticsService
|
||||
{
|
||||
private const THRESHOLDS = [
|
||||
'avg_days' => ['green' => 15, 'yellow' => 30],
|
||||
'stddev' => ['green' => 5, 'yellow' => 15],
|
||||
'late_rate' => ['green' => 0.10, 'yellow' => 0.25],
|
||||
'data_points' => ['green' => 30, 'yellow' => 10],
|
||||
];
|
||||
|
||||
private const RISK_WEIGHTS = [
|
||||
'avg_days' => 0.35,
|
||||
'late_rate' => 0.30,
|
||||
'stddev' => 0.20,
|
||||
'confidence' => 0.15,
|
||||
];
|
||||
|
||||
public function __construct(private Company $company) {}
|
||||
|
||||
/**
|
||||
* @param array<int, \stdClass> $clientSummaries
|
||||
* @param array<int, \stdClass> $companyPaymentSummary
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function generate(array $clientSummaries, array $companyPaymentSummary): array
|
||||
{
|
||||
$companySummary = ! empty($companyPaymentSummary) ? $companyPaymentSummary[0] : null;
|
||||
|
||||
$clients = [];
|
||||
|
||||
foreach ($clientSummaries as $client) {
|
||||
$indicators = [
|
||||
'avg_days' => $this->classifyMetric('avg_days', (float) ($client->avg_payment_days ?? 0)),
|
||||
'stddev' => $this->classifyMetric('stddev', (float) ($client->stddev_payment_days ?? 0)),
|
||||
'late_rate' => $this->classifyMetric('late_rate', (float) ($client->late_payment_ratio ?? 0)),
|
||||
'data_points' => $this->classifyMetric('data_points', (int) ($client->total_invoices ?? 0)),
|
||||
];
|
||||
|
||||
$riskScore = $this->calculateRiskScore($client);
|
||||
|
||||
$clients[] = [
|
||||
'client_id' => (int) $client->client_id,
|
||||
'currency_id' => (int) $client->currency_id,
|
||||
'avg_payment_days' => round((float) ($client->avg_payment_days ?? 0), 2),
|
||||
'stddev_payment_days' => round((float) ($client->stddev_payment_days ?? 0), 2),
|
||||
'total_invoices' => (int) ($client->total_invoices ?? 0),
|
||||
'late_invoices' => (int) ($client->late_invoices ?? 0),
|
||||
'late_payment_ratio' => round((float) ($client->late_payment_ratio ?? 0), 4),
|
||||
'risk_score' => $riskScore,
|
||||
'risk_level' => $this->riskLevel($riskScore),
|
||||
'indicators' => $indicators,
|
||||
];
|
||||
}
|
||||
|
||||
usort($clients, fn ($a, $b) => $b['risk_score'] <=> $a['risk_score']);
|
||||
|
||||
return [
|
||||
'company_summary' => $companySummary ? [
|
||||
'avg_payment_days' => round((float) ($companySummary->avg_payment_days ?? 0), 2),
|
||||
'stddev_payment_days' => round((float) ($companySummary->stddev_payment_days ?? 0), 2),
|
||||
'total_invoices' => (int) ($companySummary->total_invoices ?? 0),
|
||||
'late_payment_ratio' => round((float) ($companySummary->late_payment_ratio ?? 0), 4),
|
||||
] : [
|
||||
'avg_payment_days' => 0,
|
||||
'stddev_payment_days' => 0,
|
||||
'total_invoices' => 0,
|
||||
'late_payment_ratio' => 0,
|
||||
],
|
||||
'thresholds' => self::THRESHOLDS,
|
||||
'clients' => $clients,
|
||||
];
|
||||
}
|
||||
|
||||
private function classifyMetric(string $metric, float $value): string
|
||||
{
|
||||
$thresholds = self::THRESHOLDS[$metric];
|
||||
|
||||
if ($metric === 'data_points') {
|
||||
if ($value >= $thresholds['green']) {
|
||||
return 'green';
|
||||
}
|
||||
|
||||
if ($value >= $thresholds['yellow']) {
|
||||
return 'yellow';
|
||||
}
|
||||
|
||||
return 'red';
|
||||
}
|
||||
|
||||
if ($value < $thresholds['green']) {
|
||||
return 'green';
|
||||
}
|
||||
|
||||
if ($value <= $thresholds['yellow']) {
|
||||
return 'yellow';
|
||||
}
|
||||
|
||||
return 'red';
|
||||
}
|
||||
|
||||
private function calculateRiskScore(\stdClass $client): float
|
||||
{
|
||||
$avgDays = (float) ($client->avg_payment_days ?? 0);
|
||||
$avgDaysScore = min($avgDays / 45, 1.0) * 100;
|
||||
|
||||
$lateRate = (float) ($client->late_payment_ratio ?? 0);
|
||||
$lateRateScore = min($lateRate / 0.5, 1.0) * 100;
|
||||
|
||||
$stddev = (float) ($client->stddev_payment_days ?? 0);
|
||||
$stddevScore = min($stddev / 25, 1.0) * 100;
|
||||
|
||||
$dataPoints = (int) ($client->total_invoices ?? 0);
|
||||
$confidencePenalty = $dataPoints >= 30 ? 0 : (1 - ($dataPoints / 30)) * 100;
|
||||
|
||||
$score = ($avgDaysScore * self::RISK_WEIGHTS['avg_days'])
|
||||
+ ($lateRateScore * self::RISK_WEIGHTS['late_rate'])
|
||||
+ ($stddevScore * self::RISK_WEIGHTS['stddev'])
|
||||
+ ($confidencePenalty * self::RISK_WEIGHTS['confidence']);
|
||||
|
||||
return round(min(max($score, 0), 100), 2);
|
||||
}
|
||||
|
||||
private function riskLevel(float $score): string
|
||||
{
|
||||
if ($score < 33) {
|
||||
return 'low';
|
||||
}
|
||||
|
||||
if ($score <= 66) {
|
||||
return 'medium';
|
||||
}
|
||||
|
||||
return 'high';
|
||||
}
|
||||
}
|
||||
@@ -183,6 +183,8 @@ Route::group(['middleware' => ['throttle:api', 'token_auth', 'valid_json','local
|
||||
Route::post('charts/calculated_fields', [ChartController::class, 'calculatedFields'])->name('chart.calculated_fields');
|
||||
Route::post('charts/analytics_summary', [ChartController::class, 'analytics_summary'])->name('chart.analytics_summary');
|
||||
Route::post('charts/analytics_totals', [ChartController::class, 'analytics_totals'])->name('chart.analytics_totals');
|
||||
Route::post('charts/cashflow_forecast', [ChartController::class, 'cashflow_forecast'])->name('chart.cashflow_forecast');
|
||||
Route::post('charts/client_payment_analytics', [ChartController::class, 'client_payment_analytics'])->name('chart.client_payment_analytics');
|
||||
|
||||
Route::post('claim_license', [LicenseController::class, 'index'])->name('license.index');
|
||||
Route::post('check_license', [LicenseController::class, 'check'])->name('license.check');
|
||||
|
||||
Reference in New Issue
Block a user