feat(loans): backfill historical balances on loan creation (#322)

## Summary

Mirror the real-estate historical-balance pattern for loan accounts.
When a loan is created with a current balance, linearly interpolate
monthly `account_balances` rows between the loan start date
(`original_amount`) and today (current balance).

## Approach

Since we don't know how the user actually paid down the loan, use simple
linear regression between two known points:
- `(start_date, original_amount)` from `loan_details`
- `(today, current_balance)` from the latest `account_balances` row

Rows are placed on the start date, the 1st of each intermediate month,
and today.

## Changes

- **`LoanBalanceGeneratorService`** — linear interpolation + upsert on
`(account_id, balance_date)` so existing rows aren't duplicated.
- **`GenerateHistoricalLoanBalancesJob`** — `ShouldQueue`, 3 tries, 10s
backoff. Takes account, original amount, start date, current balance,
from, to.
- **`Settings/AccountController::store`** (loan branch) — after creating
`loanDetail`, if a starting balance was provided: generate the last 12
months synchronously, dispatch older window to the queue when
`start_date` predates it.
- **`LoanDetailController::update`** — when the detail is first created
for an existing account, use the most recent `AccountBalance` as the
current value and apply the same sync/async split.

## Tests

- Service: interpolation, single-balance edge, future start_date, upsert
dedupe, monthly 1st placement, from/to range.
- Job: implements `ShouldQueue`, respects from/to range.

```
Tests:    8 passed (62 assertions)
```

No dependency or directory-structure changes.
This commit is contained in:
Víctor Falcón 2026-04-24 13:09:34 +01:00 committed by GitHub
parent 74cbdd42ef
commit 5b1d059e02
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 419 additions and 6 deletions

View File

@ -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);
}

View File

@ -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;

View File

@ -0,0 +1,39 @@
<?php
namespace App\Jobs;
use App\Models\Account;
use App\Services\LoanBalanceGeneratorService;
use Carbon\Carbon;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
class GenerateHistoricalLoanBalancesJob implements ShouldQueue
{
use Queueable;
public int $tries = 3;
public int $backoff = 10;
public function __construct(
public Account $account,
public int $originalAmount,
public Carbon $startDate,
public int $currentBalance,
public Carbon $from,
public Carbon $to,
) {}
public function handle(LoanBalanceGeneratorService $service): void
{
$service->generateHistoricalBalances(
$this->account,
$this->originalAmount,
$this->startDate,
$this->currentBalance,
$this->from,
$this->to,
);
}
}

View File

@ -0,0 +1,115 @@
<?php
namespace App\Services;
use App\Models\Account;
use App\Models\AccountBalance;
use Carbon\Carbon;
use Illuminate\Support\Str;
class LoanBalanceGeneratorService
{
/**
* Generate historical monthly balances from a loan's start date to today
* using linear interpolation between the original amount owed and the
* current balance owed.
*
* Balances are placed on:
* - The loan start date (with the original amount)
* - The 1st of each month from the month after start to the current month
* - Today (with the current balance)
*
* Use $from/$to to generate only a specific date range while still
* interpolating against the full start-to-today timeline.
*/
public function generateHistoricalBalances(
Account $account,
int $originalAmount,
Carbon $startDate,
int $currentBalance,
?Carbon $from = null,
?Carbon $to = null,
): void {
$today = Carbon::today();
if ($startDate->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;
}
}

View File

@ -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');

View File

@ -0,0 +1,50 @@
<?php
use App\Jobs\GenerateHistoricalLoanBalancesJob;
use App\Models\Account;
use App\Models\User;
use App\Services\LoanBalanceGeneratorService;
use Carbon\Carbon;
use Illuminate\Contracts\Queue\ShouldQueue;
beforeEach(function () {
$this->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);
});

View File

@ -0,0 +1,151 @@
<?php
use App\Models\Account;
use App\Models\User;
use App\Services\LoanBalanceGeneratorService;
use Carbon\Carbon;
beforeEach(function () {
$this->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',
]);
});