diff --git a/app/Http/Controllers/LoanDetailController.php b/app/Http/Controllers/LoanDetailController.php index c5e641f9..466f75ee 100644 --- a/app/Http/Controllers/LoanDetailController.php +++ b/app/Http/Controllers/LoanDetailController.php @@ -3,7 +3,10 @@ namespace App\Http\Controllers; use App\Http\Requests\UpdateLoanDetailRequest; +use App\Jobs\GenerateHistoricalLoanBalancesJob; use App\Models\Account; +use App\Services\LoanBalanceGeneratorService; +use Carbon\Carbon; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Http\RedirectResponse; @@ -14,7 +17,7 @@ class LoanDetailController extends Controller /** * Update the loan detail for an account. */ - public function update(UpdateLoanDetailRequest $request, Account $account): RedirectResponse + public function update(UpdateLoanDetailRequest $request, Account $account, LoanBalanceGeneratorService $loanBalanceGenerator): RedirectResponse { $this->authorize('update', $account); @@ -34,7 +37,34 @@ class LoanDetailController extends Controller return to_route('accounts.show', $account)->withErrors($errors); } - $account->loanDetail()->create($data); + $loanDetail = $account->loanDetail()->create($data); + + $latestBalance = $account->balances()->orderByDesc('balance_date')->first(); + + if ($latestBalance !== null) { + $startDate = Carbon::parse($loanDetail->start_date); + $twelveMonthsAgo = Carbon::today()->subMonths(12)->startOfMonth(); + $currentBalance = (int) $latestBalance->balance; + + $loanBalanceGenerator->generateHistoricalBalances( + $account, + (int) $loanDetail->original_amount, + $startDate, + $currentBalance, + from: $twelveMonthsAgo, + ); + + if ($startDate->isBefore($twelveMonthsAgo)) { + GenerateHistoricalLoanBalancesJob::dispatch( + $account, + (int) $loanDetail->original_amount, + $startDate, + $currentBalance, + $startDate, + $twelveMonthsAgo->copy()->subDay(), + ); + } + } return to_route('accounts.show', $account); } diff --git a/app/Http/Controllers/Settings/AccountController.php b/app/Http/Controllers/Settings/AccountController.php index fb1d7028..85c25ce1 100644 --- a/app/Http/Controllers/Settings/AccountController.php +++ b/app/Http/Controllers/Settings/AccountController.php @@ -6,9 +6,11 @@ use App\Enums\AccountType; use App\Http\Controllers\Controller; use App\Http\Requests\Settings\StoreAccountRequest; use App\Http\Requests\Settings\UpdateAccountRequest; +use App\Jobs\GenerateHistoricalLoanBalancesJob; use App\Jobs\GenerateHistoricalRealEstateBalancesJob; use App\Models\Account; use App\Models\User; +use App\Services\LoanBalanceGeneratorService; use App\Services\RealEstateBalanceGeneratorService; use Carbon\Carbon; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; @@ -44,7 +46,7 @@ class AccountController extends Controller /** * Store a newly created account. */ - public function store(StoreAccountRequest $request, RealEstateBalanceGeneratorService $balanceGenerator): RedirectResponse|JsonResponse + public function store(StoreAccountRequest $request, RealEstateBalanceGeneratorService $balanceGenerator, LoanBalanceGeneratorService $loanBalanceGenerator): RedirectResponse|JsonResponse { /** @var User $user */ $user = Auth::user(); @@ -124,7 +126,31 @@ class AccountController extends Controller $loanData['start_date'] = now()->toDateString(); } - $account->loanDetail()->create($loanData); + $loanDetail = $account->loanDetail()->create($loanData); + + if ($balance !== null) { + $startDate = Carbon::parse($loanDetail->start_date); + $twelveMonthsAgo = Carbon::today()->subMonths(12)->startOfMonth(); + + $loanBalanceGenerator->generateHistoricalBalances( + $account, + (int) $loanDetail->original_amount, + $startDate, + $balance, + from: $twelveMonthsAgo, + ); + + if ($startDate->isBefore($twelveMonthsAgo)) { + GenerateHistoricalLoanBalancesJob::dispatch( + $account, + (int) $loanDetail->original_amount, + $startDate, + $balance, + $startDate, + $twelveMonthsAgo->copy()->subDay(), + ); + } + } } $linkedRealEstateAccountId = $validated['linked_real_estate_account_id'] ?? null; diff --git a/app/Jobs/GenerateHistoricalLoanBalancesJob.php b/app/Jobs/GenerateHistoricalLoanBalancesJob.php new file mode 100644 index 00000000..20d8fcfc --- /dev/null +++ b/app/Jobs/GenerateHistoricalLoanBalancesJob.php @@ -0,0 +1,39 @@ +generateHistoricalBalances( + $this->account, + $this->originalAmount, + $this->startDate, + $this->currentBalance, + $this->from, + $this->to, + ); + } +} diff --git a/app/Services/LoanBalanceGeneratorService.php b/app/Services/LoanBalanceGeneratorService.php new file mode 100644 index 00000000..8e67119d --- /dev/null +++ b/app/Services/LoanBalanceGeneratorService.php @@ -0,0 +1,115 @@ +isAfter($today)) { + return; + } + + $totalDays = (int) $startDate->diffInDays($today); + + if ($totalDays === 0) { + $account->balances()->updateOrCreate( + ['balance_date' => $today->toDateString()], + ['balance' => $currentBalance], + ); + + return; + } + + $rangeStart = $from ?? $startDate; + $rangeEnd = $to ?? $today; + + $dates = $this->buildDateList($startDate, $today, $rangeStart, $rangeEnd); + + if (empty($dates)) { + return; + } + + $now = now(); + $rows = []; + + foreach ($dates as $date) { + $elapsedDays = $startDate->diffInDays($date); + $balance = (int) round( + $originalAmount + ($currentBalance - $originalAmount) * ($elapsedDays / $totalDays) + ); + + $rows[] = [ + 'id' => (string) Str::uuid(), + 'account_id' => $account->id, + 'balance_date' => $date->toDateString(), + 'balance' => $balance, + 'created_at' => $now, + 'updated_at' => $now, + ]; + } + + AccountBalance::upsert($rows, ['account_id', 'balance_date'], ['balance', 'updated_at']); + } + + /** + * Build the list of dates for balance generation: + * start date, 1st of each intermediate month, and today. + * + * Only dates within $rangeStart..$rangeEnd are included. + * + * @return Carbon[] + */ + private function buildDateList(Carbon $startDate, Carbon $today, Carbon $rangeStart, Carbon $rangeEnd): array + { + $dates = []; + + if ($startDate->gte($rangeStart) && $startDate->lte($rangeEnd)) { + $dates[] = $startDate->copy(); + } + + $firstOfNextMonth = $startDate->copy()->addMonth()->startOfMonth(); + + while ($firstOfNextMonth->lte($today)) { + if ($firstOfNextMonth->gte($rangeStart) && $firstOfNextMonth->lte($rangeEnd)) { + if (! $firstOfNextMonth->isSameDay($today)) { + $dates[] = $firstOfNextMonth->copy(); + } + } + + $firstOfNextMonth->addMonth(); + } + + if (! $startDate->isSameDay($today) && $today->gte($rangeStart) && $today->lte($rangeEnd)) { + $dates[] = $today->copy(); + } + + return $dates; + } +} diff --git a/tests/Browser/BankAccountsTest.php b/tests/Browser/BankAccountsTest.php index da189b86..f443a7c5 100644 --- a/tests/Browser/BankAccountsTest.php +++ b/tests/Browser/BankAccountsTest.php @@ -201,8 +201,10 @@ it('can create a loan account with balance and loan details', function () { expect($loan)->not->toBeNull(); expect($loan->currency_code)->toBe('EUR'); - expect($loan->balances)->toHaveCount(1); - expect($loan->balances->first()->balance)->toBe(18000000); + $balances = $loan->balances()->orderBy('balance_date')->get(); + expect($balances->count())->toBeGreaterThan(1); + expect($balances->first()->balance)->toBe(25000000); + expect($balances->last()->balance)->toBe(18000000); expect($loan->loanDetail)->not->toBeNull(); expect($loan->loanDetail->loan_term_months)->toBe(360); expect((string) $loan->loanDetail->annual_interest_rate)->toBe('3.500'); diff --git a/tests/Feature/Jobs/GenerateHistoricalLoanBalancesJobTest.php b/tests/Feature/Jobs/GenerateHistoricalLoanBalancesJobTest.php new file mode 100644 index 00000000..2efddb11 --- /dev/null +++ b/tests/Feature/Jobs/GenerateHistoricalLoanBalancesJobTest.php @@ -0,0 +1,50 @@ +user = User::factory()->onboarded()->create(); +}); + +it('generates balances for the specified date range when dispatched', function () { + $this->travelTo(Carbon::parse('2026-06-15')); + + $account = Account::factory()->loan()->create([ + 'user_id' => $this->user->id, + ]); + + $job = new GenerateHistoricalLoanBalancesJob( + account: $account, + originalAmount: 15000000, + startDate: Carbon::parse('2024-01-15'), + currentBalance: 9000000, + from: Carbon::parse('2024-01-15'), + to: Carbon::parse('2025-05-31'), + ); + + $job->handle(app(LoanBalanceGeneratorService::class)); + + $balances = $account->balances()->orderBy('balance_date')->get(); + + foreach ($balances as $balance) { + expect($balance->balance_date->gte(Carbon::parse('2024-01-15')))->toBeTrue(); + expect($balance->balance_date->lte(Carbon::parse('2025-05-31')))->toBeTrue(); + } + + expect($balances->first()->balance_date->toDateString())->toBe('2024-01-15'); + expect($balances->first()->balance)->toBe(15000000); + + $dates = $balances->pluck('balance_date')->map->toDateString()->toArray(); + expect($dates)->not->toContain('2026-06-15'); + expect($dates)->not->toContain('2025-06-01'); +}); + +it('implements ShouldQueue', function () { + expect(GenerateHistoricalLoanBalancesJob::class) + ->toImplement(ShouldQueue::class); +}); diff --git a/tests/Feature/Services/LoanBalanceGeneratorServiceTest.php b/tests/Feature/Services/LoanBalanceGeneratorServiceTest.php new file mode 100644 index 00000000..c76b5c98 --- /dev/null +++ b/tests/Feature/Services/LoanBalanceGeneratorServiceTest.php @@ -0,0 +1,151 @@ +user = User::factory()->onboarded()->create(); + $this->service = app(LoanBalanceGeneratorService::class); +}); + +it('generates linearly interpolated balances from start date to today', function () { + $this->travelTo(Carbon::parse('2026-03-15')); + + $account = Account::factory()->loan()->create([ + 'user_id' => $this->user->id, + ]); + + $this->service->generateHistoricalBalances( + $account, + originalAmount: 15000000, + startDate: Carbon::parse('2025-11-15'), + currentBalance: 12000000, + ); + + $balances = $account->balances()->orderBy('balance_date')->get(); + + // start date + Dec 1 + Jan 1 + Feb 1 + Mar 1 + today (Mar 15) = 6 + expect($balances)->toHaveCount(6); + + expect($balances->first()->balance_date->toDateString())->toBe('2025-11-15'); + expect($balances->first()->balance)->toBe(15000000); + + expect($balances->last()->balance_date->toDateString())->toBe('2026-03-15'); + expect($balances->last()->balance)->toBe(12000000); + + // Values strictly decrease (loan being paid down) + for ($i = 1; $i < $balances->count(); $i++) { + expect($balances[$i]->balance)->toBeLessThanOrEqual($balances[$i - 1]->balance); + } +}); + +it('creates a single balance when start date is today', function () { + $account = Account::factory()->loan()->create([ + 'user_id' => $this->user->id, + ]); + + $this->service->generateHistoricalBalances( + $account, + originalAmount: 20000000, + startDate: Carbon::today(), + currentBalance: 20000000, + ); + + $balances = $account->balances; + + expect($balances)->toHaveCount(1); + expect($balances->first()->balance)->toBe(20000000); + expect($balances->first()->balance_date->toDateString())->toBe(now()->toDateString()); +}); + +it('does not create balances when start date is in the future', function () { + $account = Account::factory()->loan()->create([ + 'user_id' => $this->user->id, + ]); + + $this->service->generateHistoricalBalances( + $account, + originalAmount: 15000000, + startDate: Carbon::today()->addMonth(), + currentBalance: 15000000, + ); + + expect($account->balances)->toHaveCount(0); +}); + +it('uses upsert to avoid duplicate balance dates', function () { + $this->travelTo(Carbon::parse('2026-03-15')); + + $account = Account::factory()->loan()->create([ + 'user_id' => $this->user->id, + ]); + + $account->balances()->create([ + 'balance_date' => '2026-03-15', + 'balance' => 99999999, + ]); + + $this->service->generateHistoricalBalances( + $account, + originalAmount: 15000000, + startDate: Carbon::parse('2026-02-01'), + currentBalance: 12000000, + ); + + $todayBalances = $account->balances()->where('balance_date', '2026-03-15')->get(); + expect($todayBalances)->toHaveCount(1); + expect($todayBalances->first()->balance)->toBe(12000000); +}); + +it('places intermediate balances on the 1st of each month', function () { + $this->travelTo(Carbon::parse('2026-04-20')); + + $account = Account::factory()->loan()->create([ + 'user_id' => $this->user->id, + ]); + + $this->service->generateHistoricalBalances( + $account, + originalAmount: 15000000, + startDate: Carbon::parse('2026-01-15'), + currentBalance: 13000000, + ); + + $dates = $account->balances()->orderBy('balance_date')->pluck('balance_date') + ->map->toDateString()->toArray(); + + expect($dates)->toBe([ + '2026-01-15', + '2026-02-01', + '2026-03-01', + '2026-04-01', + '2026-04-20', + ]); +}); + +it('generates only balances within a from/to date range', function () { + $this->travelTo(Carbon::parse('2026-06-15')); + + $account = Account::factory()->loan()->create([ + 'user_id' => $this->user->id, + ]); + + $this->service->generateHistoricalBalances( + $account, + originalAmount: 15000000, + startDate: Carbon::parse('2026-01-15'), + currentBalance: 9000000, + from: Carbon::parse('2026-01-15'), + to: Carbon::parse('2026-02-28'), + ); + + $dates = $account->balances()->orderBy('balance_date')->pluck('balance_date') + ->map->toDateString()->toArray(); + + expect($dates)->toBe([ + '2026-01-15', + '2026-02-01', + ]); +});