feat(real-estate): auto-calculate revaluation % and generate historical balances (#253)

## Summary

- **Auto-calculates Annual Revaluation %** (CAGR) on the frontend when
purchase price, purchase date, and current market value are all provided
— pre-fills the revaluation field while still allowing manual override
- **Generates historical monthly balance records** via linear
interpolation from purchase date to today when creating a real estate
account with complete purchase data
- New `RealEstateBalanceGeneratorService` handles balance generation
with dates on the 1st of each month (matching the existing
`ApplyRealEstateRevaluationCommand` convention)

## Changes

### Backend
- **`app/Services/RealEstateBalanceGeneratorService.php`** (new) —
Linear interpolation service that builds balance dates (purchase date,
1st of each intermediate month, today) and creates `AccountBalance`
records using `updateOrCreate`
- **`app/Http/Controllers/Settings/AccountController.php`** — Calls the
service after real estate detail creation when all three inputs are
present

### Frontend
- **`resources/js/components/accounts/account-form.tsx`** — Added CAGR
auto-calculation via `useEffect` with a `useRef` flag
(`isRevaluationManuallySet`) to avoid overwriting manual user input

### Tests
- **`tests/Feature/Services/RealEstateBalanceGeneratorServiceTest.php`**
(new) — 7 unit tests covering interpolation, edge cases (same-day
purchase, flat values, single month spans)
- **`tests/Feature/RealEstateTest.php`** — 6 new feature tests covering
historical balance generation through the controller (with/without
purchase data, same-day, flat values)

All 38 tests in `RealEstateTest.php` and all 7 service tests pass.
This commit is contained in:
Víctor Falcón 2026-04-15 13:18:33 +01:00 committed by GitHub
parent 5b78509588
commit 094fb1b744
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 1133 additions and 20 deletions

View File

@ -6,8 +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\GenerateHistoricalRealEstateBalancesJob;
use App\Models\Account;
use App\Models\User;
use App\Services\RealEstateBalanceGeneratorService;
use Carbon\Carbon;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
@ -29,7 +32,7 @@ class AccountController extends Controller
$accounts = $user
->accounts()
->with(['bank:id,name,logo', 'loanDetail'])
->with(['bank:id,name,logo', 'loanDetail', 'realEstateDetail'])
->orderBy('name')
->get(['id', 'name', 'name_iv', 'encrypted', 'bank_id', 'type', 'currency_code', 'banking_connection_id']);
@ -41,7 +44,7 @@ class AccountController extends Controller
/**
* Store a newly created account.
*/
public function store(StoreAccountRequest $request): RedirectResponse|JsonResponse
public function store(StoreAccountRequest $request, RealEstateBalanceGeneratorService $balanceGenerator): RedirectResponse|JsonResponse
{
/** @var User $user */
$user = Auth::user();
@ -76,6 +79,33 @@ class AccountController extends Controller
if (! empty($realEstateData)) {
$account->realEstateDetail()->create($realEstateData);
}
// Generate historical balances when purchase data and current value are provided
if ($balance !== null && isset($validated['purchase_price'], $validated['purchase_date'])) {
$purchaseDate = Carbon::parse($validated['purchase_date']);
$twelveMonthsAgo = Carbon::today()->subMonths(12)->startOfMonth();
// Generate the last 12 months synchronously
$balanceGenerator->generateHistoricalBalances(
$account,
$validated['purchase_price'],
$purchaseDate,
$balance,
from: $twelveMonthsAgo,
);
// Dispatch older balances asynchronously if the purchase predates the sync window
if ($purchaseDate->isBefore($twelveMonthsAgo)) {
GenerateHistoricalRealEstateBalancesJob::dispatch(
$account,
$validated['purchase_price'],
$purchaseDate,
$balance,
$purchaseDate,
$twelveMonthsAgo->copy()->subDay(),
);
}
}
}
// Create loan detail if account type is loan and loan fields are provided
@ -121,7 +151,7 @@ class AccountController extends Controller
return response()->json($account, 201);
}
return back();
return redirect(url()->previousPath());
}
/**

View File

@ -0,0 +1,51 @@
<?php
namespace App\Jobs;
use App\Models\Account;
use App\Services\RealEstateBalanceGeneratorService;
use Carbon\Carbon;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
class GenerateHistoricalRealEstateBalancesJob implements ShouldQueue
{
use Queueable;
/**
* The number of times the job may be attempted.
*/
public int $tries = 3;
/**
* The number of seconds to wait before retrying the job.
*/
public int $backoff = 10;
/**
* Create a new job instance.
*/
public function __construct(
public Account $account,
public int $purchasePrice,
public Carbon $purchaseDate,
public int $currentValue,
public Carbon $from,
public Carbon $to,
) {}
/**
* Execute the job.
*/
public function handle(RealEstateBalanceGeneratorService $service): void
{
$service->generateHistoricalBalances(
$this->account,
$this->purchasePrice,
$this->purchaseDate,
$this->currentValue,
$this->from,
$this->to,
);
}
}

View File

@ -0,0 +1,119 @@
<?php
namespace App\Services;
use App\Models\Account;
use App\Models\AccountBalance;
use Carbon\Carbon;
use Illuminate\Support\Str;
class RealEstateBalanceGeneratorService
{
/**
* Generate historical monthly balances from purchase date to today
* using linear interpolation between purchase price and current value.
*
* Balances are placed on:
* - The purchase date (with purchase price)
* - The 1st of each month from the month after purchase to the current month
* - Today (with current value)
*
* Use $from/$to to generate only a specific date range while still
* interpolating against the full purchase-to-today timeline.
*/
public function generateHistoricalBalances(
Account $account,
int $purchasePrice,
Carbon $purchaseDate,
int $currentValue,
?Carbon $from = null,
?Carbon $to = null,
): void {
$today = Carbon::today();
if ($purchaseDate->isAfter($today)) {
return;
}
$totalDays = (int) $purchaseDate->diffInDays($today);
// If purchase date is today, just ensure today's balance exists
if ($totalDays === 0) {
$account->balances()->updateOrCreate(
['balance_date' => $today->toDateString()],
['balance' => $currentValue],
);
return;
}
$rangeStart = $from ?? $purchaseDate;
$rangeEnd = $to ?? $today;
$dates = $this->buildDateList($purchaseDate, $today, $rangeStart, $rangeEnd);
if (empty($dates)) {
return;
}
$now = now();
$rows = [];
foreach ($dates as $date) {
$elapsedDays = $purchaseDate->diffInDays($date);
$balance = (int) round(
$purchasePrice + ($currentValue - $purchasePrice) * ($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:
* purchase date, 1st of each intermediate month, and today.
*
* Only dates within $rangeStart..$rangeEnd are included.
*
* @return Carbon[]
*/
private function buildDateList(Carbon $purchaseDate, Carbon $today, Carbon $rangeStart, Carbon $rangeEnd): array
{
$dates = [];
// Start with the purchase date if it falls within the range
if ($purchaseDate->gte($rangeStart) && $purchaseDate->lte($rangeEnd)) {
$dates[] = $purchaseDate->copy();
}
// Add the 1st of each month from the month after purchase to the current month
$firstOfNextMonth = $purchaseDate->copy()->addMonth()->startOfMonth();
while ($firstOfNextMonth->lte($today)) {
if ($firstOfNextMonth->gte($rangeStart) && $firstOfNextMonth->lte($rangeEnd)) {
// Avoid duplicate if today is the 1st and matches this date
if (! $firstOfNextMonth->isSameDay($today)) {
$dates[] = $firstOfNextMonth->copy();
}
}
$firstOfNextMonth->addMonth();
}
// End with today if it falls within the range (unless purchase date is today, handled above)
if (! $purchaseDate->isSameDay($today) && $today->gte($rangeStart) && $today->lte($rangeEnd)) {
$dates[] = $today->copy();
}
return $dates;
}
}

View File

@ -29,7 +29,7 @@ import {
} from '@/types/account';
import { __ } from '@/utils/i18n';
import { usePage } from '@inertiajs/react';
import { useCallback, useEffect, useState } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { BankCombobox } from './bank-combobox';
import { CustomBankData, CustomBankForm } from './custom-bank-form';
@ -150,6 +150,7 @@ export function AccountForm({
const [loanData, setLoanData] = useState<LoanFormData>(
initialValues?.loan ?? initialLoanData,
);
const isRevaluationManuallySet = useRef(false);
const showBalanceField =
selectedType !== null && BALANCE_ACCOUNT_TYPES.includes(selectedType);
@ -162,6 +163,51 @@ export function AccountForm({
account.linked_loan_account_id === undefined),
);
// Auto-calculate revaluation % (CAGR) when purchase data and current value are provided
useEffect(() => {
if (!isRealEstate || isRevaluationManuallySet.current) {
return;
}
const purchasePrice = realEstateData.purchasePrice;
const purchaseDate = realEstateData.purchaseDate;
const currentValue = balance;
if (
!purchasePrice ||
purchasePrice <= 0 ||
!purchaseDate ||
!currentValue ||
currentValue <= 0
) {
return;
}
const purchaseDateObj = new Date(purchaseDate);
const today = new Date();
const diffMs = today.getTime() - purchaseDateObj.getTime();
const years = diffMs / (365.25 * 24 * 60 * 60 * 1000);
if (years <= 0) {
return;
}
const cagr =
(Math.pow(currentValue / purchasePrice, 1 / years) - 1) * 100;
const clampedCagr = Math.max(-100, Math.min(100, cagr));
const rounded = Math.round(clampedCagr * 100) / 100;
setRealEstateData((prev) => ({
...prev,
revaluationPercentage: String(rounded),
}));
}, [
isRealEstate,
realEstateData.purchasePrice,
realEstateData.purchaseDate,
balance,
]);
useEffect(() => {
onChange({
displayName,
@ -578,12 +624,13 @@ export function AccountForm({
id="purchase_price"
className="mt-1"
value={realEstateData.purchasePrice}
onChange={(value) =>
onChange={(value) => {
isRevaluationManuallySet.current = false;
setRealEstateData((prev) => ({
...prev,
purchasePrice: value,
}))
}
}));
}}
currencyCode={selectedCurrency ?? 'USD'}
/>
</div>
@ -597,12 +644,13 @@ export function AccountForm({
type="date"
className="mt-1"
value={realEstateData.purchaseDate}
onChange={(e) =>
onChange={(e) => {
isRevaluationManuallySet.current = false;
setRealEstateData((prev) => ({
...prev,
purchaseDate: e.target.value,
}))
}
}));
}}
/>
</div>
@ -735,12 +783,13 @@ export function AccountForm({
type="number"
className="mt-1"
value={realEstateData.revaluationPercentage}
onChange={(e) =>
onChange={(e) => {
isRevaluationManuallySet.current = true;
setRealEstateData((prev) => ({
...prev,
revaluationPercentage: e.target.value,
}))
}
}));
}}
placeholder="0.00"
min="-100"
max="100"

View File

@ -101,7 +101,7 @@ export function EditAccountDialog({
propertyType: detail.property_type ?? null,
address: detail.address ?? '',
purchasePrice: detail.purchase_price ?? 0,
purchaseDate: detail.purchase_date ?? '',
purchaseDate: detail.purchase_date?.slice(0, 10) ?? '',
areaValue: detail.area_value ?? '',
areaUnit: detail.area_unit ?? null,
linkedLoanAccountId: detail.linked_loan_account_id ?? null,

View File

@ -7,7 +7,6 @@ import {
flexRender,
getCoreRowModel,
getFilteredRowModel,
getPaginationRowModel,
getSortedRowModel,
Row,
SortingState,
@ -298,7 +297,6 @@ export default function Accounts({ accounts }: AccountsPageProps) {
onSortingChange: setSorting,
onColumnFiltersChange: setColumnFilters,
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
getSortedRowModel: getSortedRowModel(),
getFilteredRowModel: getFilteredRowModel(),
onColumnVisibilityChange: setColumnVisibility,

View File

@ -270,8 +270,9 @@ it('can create a real estate account linked to an existing loan', function () {
->first();
expect($account)->not->toBeNull();
expect($account->balances)->toHaveCount(1);
expect($account->balances->first()->balance)->toBe(32000000);
// Historical balances are generated from purchase_date (2024-02-01) to today
expect($account->balances()->count())->toBeGreaterThan(1);
expect($account->balances()->whereDate('balance_date', today())->first()->balance)->toBe(32000000);
$detail = RealEstateDetail::query()
->where('account_id', $account->id)

View File

@ -0,0 +1,238 @@
<?php
use App\Enums\AccountType;
use App\Models\Account;
use App\Models\AccountBalance;
use App\Models\RealEstateDetail;
use App\Models\User;
use Carbon\Carbon;
use Laravel\Pennant\Feature;
use function Pest\Laravel\actingAs;
beforeEach(function () {
$this->user = User::factory()->onboarded()->create();
Feature::for($this->user)->activate('real-estate');
actingAs($this->user);
});
it('shows real estate fields when real estate type is selected', function () {
$page = visit('/settings/accounts');
$page->waitForText('Create Account')
->click('Create Account')
->wait(1)
->click('Select account type')
->wait(1)
->click('[role="option"]:has-text("Real Estate")')
->wait(1)
->assertSee('Property Type')
->assertSee('Purchase Price')
->assertSee('Purchase Date')
->assertSee('Annual Revaluation (%)')
->assertNoJavascriptErrors();
});
it('auto-calculates revaluation percentage from purchase data and current value', function () {
$page = visit('/settings/accounts');
$page->waitForText('Create Account')
->click('Create Account')
->wait(1)
->click('Select account type')
->wait(1)
->click('[role="option"]:has-text("Real Estate")')
->wait(1)
->click('Select currency')
->wait(1)
->click('[role="option"]:has-text("EUR")')
->wait(1);
// Fill current value (balance) — source input for CAGR calculation
$page->fill('#balance', '240000')
->keys('#balance', ['Tab'])
->wait(0.5);
// Fill purchase price
$page->fill('#purchase_price', '200000')
->keys('#purchase_price', ['Tab'])
->wait(0.5);
// Fill purchase date (2 years ago)
$page->fill('#purchase_date', date('Y-m-d', strtotime('-2 years')))
->keys('#purchase_date', ['Tab'])
->wait(1);
// Revaluation % should be auto-filled by the CAGR effect
$page->assertValueIsNot('#revaluation_percentage', '')
->assertNoJavascriptErrors();
});
it('manual revaluation percentage is preserved when balance changes', function () {
$page = visit('/settings/accounts');
$page->waitForText('Create Account')
->click('Create Account')
->wait(1)
->click('Select account type')
->wait(1)
->click('[role="option"]:has-text("Real Estate")')
->wait(1)
->click('Select currency')
->wait(1)
->click('[role="option"]:has-text("EUR")')
->wait(1);
// Set purchase data so auto-calc fires initially
$page->fill('#purchase_price', '200000')
->keys('#purchase_price', ['Tab'])
->wait(0.5)
->fill('#purchase_date', date('Y-m-d', strtotime('-2 years')))
->keys('#purchase_date', ['Tab'])
->wait(0.5)
->fill('#balance', '240000')
->keys('#balance', ['Tab'])
->wait(1);
// Override revaluation % manually
$page->clear('#revaluation_percentage')
->fill('#revaluation_percentage', '5.00')
->wait(0.5);
// Change balance — manual override should survive
$page->fill('#balance', '250000')
->keys('#balance', ['Tab'])
->wait(1);
$page->assertValue('#revaluation_percentage', '5.00')
->assertNoJavascriptErrors();
});
it('creates real estate account and generates historical balances', function () {
$purchaseDate = date('Y-m-d', strtotime('-14 months'));
$page = visit('/settings/accounts');
$page->waitForText('Create Account')
->click('Create Account')
->wait(1)
->fill('#display_name', 'My Investment Property')
->click('Select account type')
->wait(1)
->click('[role="option"]:has-text("Real Estate")')
->wait(1)
->click('Select currency')
->wait(1)
->click('[role="option"]:has-text("EUR")')
->wait(1)
->click('Select property type')
->wait(1)
->click('[role="option"]:has-text("Residential")')
->wait(1);
$page->fill('#balance', '240000')
->keys('#balance', ['Tab'])
->wait(0.5)
->fill('#purchase_price', '200000')
->keys('#purchase_price', ['Tab'])
->wait(0.5)
->fill('#purchase_date', $purchaseDate)
->keys('#purchase_date', ['Tab'])
->wait(1);
$page->click('Create')
->wait(3)
->waitForText('My Investment Property')
->assertNoJavascriptErrors();
$account = Account::query()
->where('user_id', $this->user->id)
->where('type', AccountType::RealEstate->value)
->first();
expect($account)->not->toBeNull();
// 14 months back → purchase date + ~14 month-starts + today = >2 balances
expect($account->balances()->count())->toBeGreaterThan(2);
});
it('balance chart shows historical data after account creation', function () {
$purchaseDate = Carbon::today()->subMonths(6)->toDateString();
$account = Account::factory()->realEstate()->create([
'user_id' => $this->user->id,
'currency_code' => 'EUR',
]);
RealEstateDetail::factory()->create([
'account_id' => $account->id,
'purchase_price' => 20000000,
'purchase_date' => $purchaseDate,
]);
// Seed historical balances so the chart has data
AccountBalance::factory()->create([
'account_id' => $account->id,
'balance_date' => $purchaseDate,
'balance' => 20000000,
]);
AccountBalance::factory()->create([
'account_id' => $account->id,
'balance_date' => Carbon::today()->toDateString(),
'balance' => 24000000,
]);
$page = visit('/accounts/'.$account->id);
$page->waitForText('Market value evolution')
->assertDontSee('No market value data available')
->assertNoJavascriptErrors();
});
it('edit dialog pre-fills purchase date in correct format', function () {
$purchaseDate = '2023-06-15';
$account = Account::factory()->realEstate()->create([
'user_id' => $this->user->id,
'currency_code' => 'EUR',
]);
RealEstateDetail::factory()->create([
'account_id' => $account->id,
'purchase_date' => $purchaseDate,
]);
$page = visit('/settings/accounts');
$page->waitForText($account->name)
->click('[aria-label="Open menu"]')
->wait(1)
->click('Edit')
->wait(1)
->assertValue('#purchase_date', $purchaseDate)
->assertNoJavascriptErrors();
});
it('redirects back to settings after creating an account', function () {
$page = visit('/settings/accounts');
$page->waitForText('Create Account')
->click('Create Account')
->wait(1)
->fill('#display_name', 'Test Redirect Account')
->click('Select account type')
->wait(1)
->click('[role="option"]:has-text("Real Estate")')
->wait(1)
->click('Select currency')
->wait(1)
->click('[role="option"]:has-text("EUR")')
->wait(1)
->click('Select property type')
->wait(1)
->click('[role="option"]:has-text("Residential")')
->wait(1)
->click('Create')
->wait(3)
->assertPathIs('/settings/accounts')
->assertNoJavascriptErrors();
});

View File

@ -430,7 +430,7 @@ test('real estate balance evolution includes mortgage balance data', function ()
$loanAccount = Account::factory()->create([
'user_id' => $this->user->id,
'type' => AccountType::Loan,
'currency_code' => 'USD',
'currency_code' => 'EUR',
]);
AccountBalance::factory()->create([
@ -446,7 +446,7 @@ test('real estate balance evolution includes mortgage balance data', function ()
$realEstateAccount = Account::factory()->realEstate()->create([
'user_id' => $this->user->id,
'currency_code' => 'USD',
'currency_code' => 'EUR',
]);
AccountBalance::factory()->create([

View File

@ -0,0 +1,53 @@
<?php
use App\Jobs\GenerateHistoricalRealEstateBalancesJob;
use App\Models\Account;
use App\Models\User;
use App\Services\RealEstateBalanceGeneratorService;
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()->realEstate()->create([
'user_id' => $this->user->id,
]);
$job = new GenerateHistoricalRealEstateBalancesJob(
account: $account,
purchasePrice: 10000000,
purchaseDate: Carbon::parse('2024-01-15'),
currentValue: 16000000,
from: Carbon::parse('2024-01-15'),
to: Carbon::parse('2025-05-31'),
);
$job->handle(app(RealEstateBalanceGeneratorService::class));
$balances = $account->balances()->orderBy('balance_date')->get();
// Should only contain dates within the specified range
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();
}
// First balance should be the purchase date
expect($balances->first()->balance_date->toDateString())->toBe('2024-01-15');
expect($balances->first()->balance)->toBe(10000000);
// Should not include today or any dates after the range
$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(GenerateHistoricalRealEstateBalancesJob::class)
->toImplement(ShouldQueue::class);
});

View File

@ -2,6 +2,7 @@
use App\Enums\AccountType;
use App\Enums\PropertyType;
use App\Jobs\GenerateHistoricalRealEstateBalancesJob;
use App\Models\Account;
use App\Models\Bank;
use App\Models\RealEstateDetail;
@ -848,3 +849,258 @@ it('creates real estate detail via account update if it does not exist', functio
'revaluation_percentage' => '1.50',
]);
});
// -------------------------------------------------------------------
// Historical balance generation on account creation
// -------------------------------------------------------------------
it('generates historical balances when creating real estate account with purchase data', function () {
$this->travelTo(Carbon\Carbon::parse('2026-03-15'));
actingAs($this->user);
$data = [
'name' => 'My House',
'currency_code' => 'EUR',
'type' => AccountType::RealEstate->value,
'property_type' => PropertyType::Residential->value,
'purchase_price' => 20000000, // 200,000.00
'purchase_date' => '2025-11-15',
'balance' => 24000000, // 240,000.00 (current value)
];
$response = $this->post(route('accounts.store'), $data);
$response->assertRedirect();
$account = Account::query()
->where('user_id', $this->user->id)
->where('type', AccountType::RealEstate->value)
->first();
$balances = $account->balances()->orderBy('balance_date')->get();
// Expect: purchase date, Dec 1, Jan 1, Feb 1, Mar 1, and today (Mar 15)
expect($balances)->toHaveCount(6);
// First balance should be the purchase price on the purchase date
expect($balances[0]->balance_date->toDateString())->toBe('2025-11-15');
expect($balances[0]->balance)->toBe(20000000);
// Last balance should be the current value on today
expect($balances[5]->balance_date->toDateString())->toBe('2026-03-15');
expect($balances[5]->balance)->toBe(24000000);
// Intermediate balances should be linearly interpolated
// Total days: Nov 15 to Mar 15 = 120 days
$totalDays = 120;
// Dec 1: 16 days elapsed
expect($balances[1]->balance_date->toDateString())->toBe('2025-12-01');
expect($balances[1]->balance)->toBe((int) round(20000000 + 4000000 * (16 / $totalDays)));
// Jan 1: 47 days elapsed
expect($balances[2]->balance_date->toDateString())->toBe('2026-01-01');
expect($balances[2]->balance)->toBe((int) round(20000000 + 4000000 * (47 / $totalDays)));
// Feb 1: 78 days elapsed
expect($balances[3]->balance_date->toDateString())->toBe('2026-02-01');
expect($balances[3]->balance)->toBe((int) round(20000000 + 4000000 * (78 / $totalDays)));
// Mar 1: 106 days elapsed
expect($balances[4]->balance_date->toDateString())->toBe('2026-03-01');
expect($balances[4]->balance)->toBe((int) round(20000000 + 4000000 * (106 / $totalDays)));
});
it('does not generate historical balances when purchase_price is missing', function () {
actingAs($this->user);
$data = [
'name' => 'My House',
'currency_code' => 'EUR',
'type' => AccountType::RealEstate->value,
'property_type' => PropertyType::Residential->value,
'purchase_date' => '2025-06-01',
'balance' => 30000000,
];
$this->post(route('accounts.store'), $data);
$account = Account::query()
->where('user_id', $this->user->id)
->where('type', AccountType::RealEstate->value)
->first();
// Only today's balance should exist (from the regular balance creation)
expect($account->balances)->toHaveCount(1);
expect($account->balances->first()->balance)->toBe(30000000);
});
it('does not generate historical balances when purchase_date is missing', function () {
actingAs($this->user);
$data = [
'name' => 'My House',
'currency_code' => 'EUR',
'type' => AccountType::RealEstate->value,
'property_type' => PropertyType::Residential->value,
'purchase_price' => 20000000,
'balance' => 30000000,
];
$this->post(route('accounts.store'), $data);
$account = Account::query()
->where('user_id', $this->user->id)
->where('type', AccountType::RealEstate->value)
->first();
// Only today's balance should exist
expect($account->balances)->toHaveCount(1);
expect($account->balances->first()->balance)->toBe(30000000);
});
it('handles purchase_date equal to today when generating historical balances', function () {
actingAs($this->user);
$data = [
'name' => 'New Purchase',
'currency_code' => 'EUR',
'type' => AccountType::RealEstate->value,
'property_type' => PropertyType::Land->value,
'purchase_price' => 15000000,
'purchase_date' => now()->toDateString(),
'balance' => 15000000,
];
$this->post(route('accounts.store'), $data);
$account = Account::query()
->where('user_id', $this->user->id)
->where('type', AccountType::RealEstate->value)
->first();
// Only one balance for today
expect($account->balances)->toHaveCount(1);
expect($account->balances->first()->balance_date->toDateString())->toBe(now()->toDateString());
expect($account->balances->first()->balance)->toBe(15000000);
});
it('generates flat balances when purchase_price equals current value', function () {
$this->travelTo(Carbon\Carbon::parse('2026-03-15'));
actingAs($this->user);
$data = [
'name' => 'Flat Value Property',
'currency_code' => 'USD',
'type' => AccountType::RealEstate->value,
'property_type' => PropertyType::Commercial->value,
'purchase_price' => 50000000,
'purchase_date' => '2026-01-01',
'balance' => 50000000,
];
$this->post(route('accounts.store'), $data);
$account = Account::query()
->where('user_id', $this->user->id)
->where('type', AccountType::RealEstate->value)
->first();
$balances = $account->balances()->orderBy('balance_date')->get();
// All balances should be the same value
foreach ($balances as $balance) {
expect($balance->balance)->toBe(50000000);
}
});
it('does not generate historical balances when balance is not provided', function () {
actingAs($this->user);
$data = [
'name' => 'No Balance Property',
'currency_code' => 'USD',
'type' => AccountType::RealEstate->value,
'property_type' => PropertyType::Residential->value,
'purchase_price' => 20000000,
'purchase_date' => '2025-06-01',
];
$this->post(route('accounts.store'), $data);
$account = Account::query()
->where('user_id', $this->user->id)
->where('type', AccountType::RealEstate->value)
->first();
// No balances should be created
expect($account->balances)->toHaveCount(0);
});
it('dispatches a job for older balances when purchase predates 12-month window', function () {
Bus::fake(GenerateHistoricalRealEstateBalancesJob::class);
$this->travelTo(Carbon\Carbon::parse('2026-03-15'));
actingAs($this->user);
$data = [
'name' => 'Old Property',
'currency_code' => 'EUR',
'type' => AccountType::RealEstate->value,
'property_type' => PropertyType::Residential->value,
'purchase_price' => 10000000,
'purchase_date' => '2020-06-15', // ~6 years ago
'balance' => 18000000,
];
$response = $this->post(route('accounts.store'), $data);
$response->assertRedirect();
Bus::assertDispatched(GenerateHistoricalRealEstateBalancesJob::class, function ($job) {
return $job->purchaseDate->toDateString() === '2020-06-15'
&& $job->purchasePrice === 10000000
&& $job->currentValue === 18000000;
});
$account = Account::query()
->where('user_id', $this->user->id)
->where('type', AccountType::RealEstate->value)
->first();
// Only the last 12 months of balances should exist synchronously
$balances = $account->balances()->orderBy('balance_date')->get();
$twelveMonthsAgo = Carbon\Carbon::parse('2025-03-01');
foreach ($balances as $balance) {
expect($balance->balance_date->gte($twelveMonthsAgo))->toBeTrue();
}
// Today's balance should be the current value
$todayBalance = $balances->last();
expect($todayBalance->balance_date->toDateString())->toBe('2026-03-15');
expect($todayBalance->balance)->toBe(18000000);
});
it('does not dispatch a job when purchase is within 12-month window', function () {
Bus::fake(GenerateHistoricalRealEstateBalancesJob::class);
$this->travelTo(Carbon\Carbon::parse('2026-03-15'));
actingAs($this->user);
$data = [
'name' => 'Recent Property',
'currency_code' => 'EUR',
'type' => AccountType::RealEstate->value,
'property_type' => PropertyType::Residential->value,
'purchase_price' => 20000000,
'purchase_date' => '2025-11-15', // within last 12 months
'balance' => 24000000,
];
$this->post(route('accounts.store'), $data);
Bus::assertNotDispatched(GenerateHistoricalRealEstateBalancesJob::class);
});

View File

@ -0,0 +1,293 @@
<?php
use App\Models\Account;
use App\Models\User;
use App\Services\RealEstateBalanceGeneratorService;
use Carbon\Carbon;
beforeEach(function () {
$this->user = User::factory()->onboarded()->create();
$this->service = app(RealEstateBalanceGeneratorService::class);
});
it('generates linearly interpolated balances from purchase date to today', function () {
$this->travelTo(Carbon::parse('2026-03-15'));
$account = Account::factory()->realEstate()->create([
'user_id' => $this->user->id,
]);
$this->service->generateHistoricalBalances(
$account,
purchasePrice: 20000000, // 200,000.00
purchaseDate: Carbon::parse('2025-11-15'),
currentValue: 24000000, // 240,000.00
);
$balances = $account->balances()->orderBy('balance_date')->get();
// purchase date + Dec 1 + Jan 1 + Feb 1 + Mar 1 + today (Mar 15) = 6
expect($balances)->toHaveCount(6);
// First = purchase price
expect($balances->first()->balance_date->toDateString())->toBe('2025-11-15');
expect($balances->first()->balance)->toBe(20000000);
// Last = current value
expect($balances->last()->balance_date->toDateString())->toBe('2026-03-15');
expect($balances->last()->balance)->toBe(24000000);
// Values should strictly increase (since current > purchase)
for ($i = 1; $i < $balances->count(); $i++) {
expect($balances[$i]->balance)->toBeGreaterThanOrEqual($balances[$i - 1]->balance);
}
});
it('creates a single balance when purchase date is today', function () {
$account = Account::factory()->realEstate()->create([
'user_id' => $this->user->id,
]);
$this->service->generateHistoricalBalances(
$account,
purchasePrice: 30000000,
purchaseDate: Carbon::today(),
currentValue: 30000000,
);
$balances = $account->balances;
expect($balances)->toHaveCount(1);
expect($balances->first()->balance)->toBe(30000000);
expect($balances->first()->balance_date->toDateString())->toBe(now()->toDateString());
});
it('generates flat balances when purchase price equals current value', function () {
$this->travelTo(Carbon::parse('2026-06-01'));
$account = Account::factory()->realEstate()->create([
'user_id' => $this->user->id,
]);
$this->service->generateHistoricalBalances(
$account,
purchasePrice: 50000000,
purchaseDate: Carbon::parse('2026-01-01'),
currentValue: 50000000,
);
$balances = $account->balances;
foreach ($balances as $balance) {
expect($balance->balance)->toBe(50000000);
}
});
it('does not create balances when purchase date is in the future', function () {
$account = Account::factory()->realEstate()->create([
'user_id' => $this->user->id,
]);
$this->service->generateHistoricalBalances(
$account,
purchasePrice: 20000000,
purchaseDate: Carbon::today()->addMonth(),
currentValue: 20000000,
);
expect($account->balances)->toHaveCount(0);
});
it('uses updateOrCreate to avoid duplicate balance dates', function () {
$this->travelTo(Carbon::parse('2026-03-15'));
$account = Account::factory()->realEstate()->create([
'user_id' => $this->user->id,
]);
// Create an existing balance for today
$account->balances()->create([
'balance_date' => '2026-03-15',
'balance' => 99999999,
]);
$this->service->generateHistoricalBalances(
$account,
purchasePrice: 20000000,
purchaseDate: Carbon::parse('2026-02-01'),
currentValue: 24000000,
);
// Today's balance should be updated, not duplicated
$todayBalances = $account->balances()->where('balance_date', '2026-03-15')->get();
expect($todayBalances)->toHaveCount(1);
expect($todayBalances->first()->balance)->toBe(24000000);
});
it('handles depreciation (current value less than purchase price)', function () {
$this->travelTo(Carbon::parse('2026-06-01'));
$account = Account::factory()->realEstate()->create([
'user_id' => $this->user->id,
]);
$this->service->generateHistoricalBalances(
$account,
purchasePrice: 30000000,
purchaseDate: Carbon::parse('2026-01-01'),
currentValue: 27000000,
);
$balances = $account->balances()->orderBy('balance_date')->get();
// First = purchase price, last = current (lower)
expect($balances->first()->balance)->toBe(30000000);
expect($balances->last()->balance)->toBe(27000000);
// Values should decrease
for ($i = 1; $i < $balances->count(); $i++) {
expect($balances[$i]->balance)->toBeLessThanOrEqual($balances[$i - 1]->balance);
}
});
it('places intermediate balances on the 1st of each month', function () {
$this->travelTo(Carbon::parse('2026-04-20'));
$account = Account::factory()->realEstate()->create([
'user_id' => $this->user->id,
]);
$this->service->generateHistoricalBalances(
$account,
purchasePrice: 10000000,
purchaseDate: Carbon::parse('2026-01-15'),
currentValue: 12000000,
);
$balances = $account->balances()->orderBy('balance_date')->get();
$dates = $balances->pluck('balance_date')->map->toDateString()->toArray();
expect($dates)->toBe([
'2026-01-15', // purchase date
'2026-02-01', // 1st of month
'2026-03-01', // 1st of month
'2026-04-01', // 1st of month
'2026-04-20', // today
]);
});
it('generates only balances within a from/to date range', function () {
$this->travelTo(Carbon::parse('2026-06-15'));
$account = Account::factory()->realEstate()->create([
'user_id' => $this->user->id,
]);
// Purchase was Jan 15, today is Jun 15 — but only generate Mar 1 to Jun 15
$this->service->generateHistoricalBalances(
$account,
purchasePrice: 10000000,
purchaseDate: Carbon::parse('2026-01-15'),
currentValue: 16000000,
from: Carbon::parse('2026-03-01'),
);
$balances = $account->balances()->orderBy('balance_date')->get();
$dates = $balances->pluck('balance_date')->map->toDateString()->toArray();
// Should only include dates from Mar 1 onward (no purchase date, no Feb 1)
expect($dates)->toBe([
'2026-03-01',
'2026-04-01',
'2026-05-01',
'2026-06-01',
'2026-06-15', // today
]);
// Interpolation should still be based on the full timeline (Jan 15 to Jun 15 = 151 days)
$totalDays = 151;
// Mar 1: 45 days elapsed from Jan 15
expect($balances[0]->balance)->toBe((int) round(10000000 + 6000000 * (45 / $totalDays)));
// Today (Jun 15) should be the current value
expect($balances->last()->balance)->toBe(16000000);
});
it('generates only balances within a from/to range excluding today', function () {
$this->travelTo(Carbon::parse('2026-06-15'));
$account = Account::factory()->realEstate()->create([
'user_id' => $this->user->id,
]);
// Generate only the older portion: purchase date to Feb 28
$this->service->generateHistoricalBalances(
$account,
purchasePrice: 10000000,
purchaseDate: Carbon::parse('2026-01-15'),
currentValue: 16000000,
from: Carbon::parse('2026-01-15'),
to: Carbon::parse('2026-02-28'),
);
$balances = $account->balances()->orderBy('balance_date')->get();
$dates = $balances->pluck('balance_date')->map->toDateString()->toArray();
expect($dates)->toBe([
'2026-01-15', // purchase date
'2026-02-01', // 1st of month
]);
// Purchase date should be purchase price
expect($balances[0]->balance)->toBe(10000000);
});
it('combines two ranged calls to produce the same result as a full generation', function () {
$this->travelTo(Carbon::parse('2026-06-15'));
// Generate full range on one account
$fullAccount = Account::factory()->realEstate()->create([
'user_id' => $this->user->id,
]);
$this->service->generateHistoricalBalances(
$fullAccount,
purchasePrice: 10000000,
purchaseDate: Carbon::parse('2026-01-15'),
currentValue: 16000000,
);
// Generate split ranges on another account
$splitAccount = Account::factory()->realEstate()->create([
'user_id' => $this->user->id,
]);
$splitPoint = Carbon::parse('2026-03-01');
$this->service->generateHistoricalBalances(
$splitAccount,
purchasePrice: 10000000,
purchaseDate: Carbon::parse('2026-01-15'),
currentValue: 16000000,
from: Carbon::parse('2026-01-15'),
to: $splitPoint->copy()->subDay(),
);
$this->service->generateHistoricalBalances(
$splitAccount,
purchasePrice: 10000000,
purchaseDate: Carbon::parse('2026-01-15'),
currentValue: 16000000,
from: $splitPoint,
);
$fullBalances = $fullAccount->balances()->orderBy('balance_date')->get();
$splitBalances = $splitAccount->balances()->orderBy('balance_date')->get();
expect($splitBalances->pluck('balance_date')->map->toDateString()->toArray())
->toBe($fullBalances->pluck('balance_date')->map->toDateString()->toArray());
expect($splitBalances->pluck('balance')->toArray())
->toBe($fullBalances->pluck('balance')->toArray());
});

View File

@ -3,6 +3,7 @@
use App\Enums\AccountType;
use App\Models\Account;
use App\Models\Bank;
use App\Models\RealEstateDetail;
use App\Models\Transaction;
use App\Models\User;
@ -341,3 +342,27 @@ it('validates balance must be an integer when provided', function () {
$response->assertSessionHasErrors(['balance']);
});
it('includes real estate detail when listing accounts with real estate type', function () {
actingAs($this->user);
$account = Account::factory()->realEstate()->create([
'user_id' => $this->user->id,
]);
$realEstateDetail = RealEstateDetail::factory()->create([
'account_id' => $account->id,
]);
$response = $this->get(route('accounts.index'));
$response->assertSuccessful();
$response->assertInertia(fn ($page) => $page
->component('settings/accounts')
->has('accounts', 1)
->has('accounts.0.real_estate_detail')
->where('accounts.0.real_estate_detail.property_type', $realEstateDetail->property_type->value)
->where('accounts.0.real_estate_detail.address', $realEstateDetail->address)
->where('accounts.0.real_estate_detail.purchase_price', $realEstateDetail->purchase_price)
);
});