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:
parent
74cbdd42ef
commit
5b1d059e02
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
@ -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',
|
||||
]);
|
||||
});
|
||||
Loading…
Reference in New Issue