whisper-money/app/Models/SavingsGoal.php

202 lines
6.6 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
namespace App\Models;
use App\Models\Concerns\BelongsToSpace;
use Database\Factories\SavingsGoalFactory;
use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Support\Carbon;
/**
* @property Carbon $created_at
* @property Carbon|null $target_date
*/
class SavingsGoal extends Model
{
/** @use HasFactory<SavingsGoalFactory> */
use BelongsToSpace, HasFactory, HasUuids, SoftDeletes;
protected $fillable = [
'user_id',
'space_id',
'label_id',
'name',
'target_amount',
'target_date',
];
/** @var list<string> */
protected $hidden = [
'space_id',
];
protected function casts(): array
{
return [
'target_amount' => 'integer',
'target_date' => 'date:Y-m-d',
];
}
/** @return BelongsTo<User, $this> */
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
/** @return BelongsTo<Label, $this> */
public function label(): BelongsTo
{
return $this->belongsTo(Label::class);
}
/**
* Money set aside toward the goal, in cents. Contributions are outflows, so
* the natural transaction sign is negated (same convention as budgets): a
* transfer out () adds, a withdrawal back (+) subtracts.
*/
public function savedAmountInCents(): int
{
if ($this->label === null) {
return 0;
}
return -1 * (int) $this->label->transactions()->sum('transactions.amount');
}
/**
* All of a user's goals with their computed progress, for the combined
* budgets/goals index. Kept here (not in the budget controller) so budgets
* stay decoupled from goals.
*
* @return list<array<string, mixed>>
*/
public static function withStatsForUser(User $user): array
{
$goals = $user->savingsGoals()->with('label')->get();
// ponytail: one grouped sum+min for all goals' labels avoids N+1 across the list.
$aggByLabel = Transaction::query()
->join('label_transaction', 'label_transaction.transaction_id', '=', 'transactions.id')
->whereIn('label_transaction.label_id', $goals->pluck('label_id')->filter())
->groupBy('label_transaction.label_id')
->selectRaw('label_transaction.label_id as label_id, SUM(transactions.amount) as total, MIN(transactions.transaction_date) as earliest')
->get()
->keyBy('label_id');
return $goals->map(function (SavingsGoal $goal) use ($aggByLabel): array {
$agg = $aggByLabel->get($goal->label_id);
// Negated net flow, mirroring savedAmountInCents(); batched to avoid N+1.
$saved = -1 * (int) ($agg->total ?? 0);
return array_merge($goal->toArray(), [
'stats' => self::project(
$saved,
$goal->target_amount,
self::effectiveStart($goal->created_at, $agg->earliest ?? null),
$goal->target_date,
now(),
),
]);
})->all();
}
/**
* The goal's timeline start: the earlier of its creation date and its first
* tagged transaction. Tagging pre-existing savings must not compress the
* elapsed window, which would otherwise inflate the rate and projection.
*/
public static function effectiveStart(Carbon $createdAt, Carbon|string|null $earliestContribution): Carbon
{
$start = $createdAt->copy()->startOfDay();
if ($earliestContribution === null) {
return $start;
}
$earliest = Carbon::parse($earliestContribution)->startOfDay();
return $earliest->lt($start) ? $earliest : $start;
}
/**
* Linear progress + projection, computed from primitives so it stays a pure,
* testable function. Dates are day-granular. `rate_per_day` is cents/day and
* feeds the chart's dotted projection line client-side.
*
* @return array{
* saved: int,
* target: int,
* percentage: float,
* target_date: ?string,
* rate_per_day: float,
* expected_today: ?int,
* status: ?string,
* estimated_date: ?string,
* required_per_month: ?int,
* }
*/
public static function project(int $saved, int $target, Carbon $start, ?Carbon $targetDate, Carbon $today): array
{
$start = $start->copy()->startOfDay();
$today = $today->copy()->startOfDay();
$daysElapsed = max(1, $start->diffInDays($today));
$ratePerDay = $saved / $daysElapsed;
$remaining = $target - $saved;
$percentage = $target > 0 ? round(($saved / $target) * 100, 1) : 0.0;
$estimatedDate = null;
if ($saved >= $target) {
$estimatedDate = $today->toDateString();
} elseif ($ratePerDay > 0) {
$estimatedDate = $today->copy()->addDays((int) ceil($remaining / $ratePerDay))->toDateString();
}
$expectedToday = null;
$status = null;
$requiredPerMonth = null;
if ($targetDate !== null) {
$targetDate = $targetDate->copy()->startOfDay();
$totalDays = max(1, $start->diffInDays($targetDate));
$expectedToday = (int) round($target * min($daysElapsed, $totalDays) / $totalDays);
$tolerance = $target * 0.02;
if ($saved >= $target) {
$status = 'completed';
} elseif ($saved < $expectedToday - $tolerance) {
$status = 'behind';
} elseif ($saved > $expectedToday + $tolerance) {
$status = 'ahead';
} else {
$status = 'on_track';
}
$daysLeft = $today->diffInDays($targetDate, false);
if ($remaining > 0 && $daysLeft > 0) {
$requiredPerMonth = (int) round(($remaining / $daysLeft) * 30);
} elseif ($remaining <= 0) {
$requiredPerMonth = 0;
}
}
return [
'saved' => $saved,
'target' => $target,
'percentage' => $percentage,
'target_date' => $targetDate?->toDateString(),
'rate_per_day' => round($ratePerDay, 2),
'expected_today' => $expectedToday,
'status' => $status,
'estimated_date' => $estimatedDate,
'required_per_month' => $requiredPerMonth,
];
}
}