diff --git a/app/Http/Controllers/Settings/AccountController.php b/app/Http/Controllers/Settings/AccountController.php index a4a17eae..fb1d7028 100644 --- a/app/Http/Controllers/Settings/AccountController.php +++ b/app/Http/Controllers/Settings/AccountController.php @@ -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()); } /** diff --git a/app/Jobs/GenerateHistoricalRealEstateBalancesJob.php b/app/Jobs/GenerateHistoricalRealEstateBalancesJob.php new file mode 100644 index 00000000..9d7ffb81 --- /dev/null +++ b/app/Jobs/GenerateHistoricalRealEstateBalancesJob.php @@ -0,0 +1,51 @@ +generateHistoricalBalances( + $this->account, + $this->purchasePrice, + $this->purchaseDate, + $this->currentValue, + $this->from, + $this->to, + ); + } +} diff --git a/app/Services/RealEstateBalanceGeneratorService.php b/app/Services/RealEstateBalanceGeneratorService.php new file mode 100644 index 00000000..dbf3a8df --- /dev/null +++ b/app/Services/RealEstateBalanceGeneratorService.php @@ -0,0 +1,119 @@ +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; + } +} diff --git a/resources/js/components/accounts/account-form.tsx b/resources/js/components/accounts/account-form.tsx index daae817d..03bcb2ef 100644 --- a/resources/js/components/accounts/account-form.tsx +++ b/resources/js/components/accounts/account-form.tsx @@ -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( 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'} /> @@ -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, - })) - } + })); + }} /> @@ -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" diff --git a/resources/js/components/accounts/edit-account-dialog.tsx b/resources/js/components/accounts/edit-account-dialog.tsx index 54169815..0bc1ce5d 100644 --- a/resources/js/components/accounts/edit-account-dialog.tsx +++ b/resources/js/components/accounts/edit-account-dialog.tsx @@ -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, diff --git a/resources/js/pages/settings/accounts.tsx b/resources/js/pages/settings/accounts.tsx index cc02d2a6..80504987 100644 --- a/resources/js/pages/settings/accounts.tsx +++ b/resources/js/pages/settings/accounts.tsx @@ -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, diff --git a/tests/Browser/BankAccountsTest.php b/tests/Browser/BankAccountsTest.php index d805c36f..8915af43 100644 --- a/tests/Browser/BankAccountsTest.php +++ b/tests/Browser/BankAccountsTest.php @@ -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) diff --git a/tests/Browser/RealEstateAccountTest.php b/tests/Browser/RealEstateAccountTest.php new file mode 100644 index 00000000..83b69671 --- /dev/null +++ b/tests/Browser/RealEstateAccountTest.php @@ -0,0 +1,238 @@ +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(); +}); diff --git a/tests/Feature/AccountControllerTest.php b/tests/Feature/AccountControllerTest.php index 33d044a5..cb80f179 100644 --- a/tests/Feature/AccountControllerTest.php +++ b/tests/Feature/AccountControllerTest.php @@ -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([ diff --git a/tests/Feature/Jobs/GenerateHistoricalRealEstateBalancesJobTest.php b/tests/Feature/Jobs/GenerateHistoricalRealEstateBalancesJobTest.php new file mode 100644 index 00000000..359b1f8d --- /dev/null +++ b/tests/Feature/Jobs/GenerateHistoricalRealEstateBalancesJobTest.php @@ -0,0 +1,53 @@ +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); +}); diff --git a/tests/Feature/RealEstateTest.php b/tests/Feature/RealEstateTest.php index 3075c92b..b42bea26 100644 --- a/tests/Feature/RealEstateTest.php +++ b/tests/Feature/RealEstateTest.php @@ -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); +}); diff --git a/tests/Feature/Services/RealEstateBalanceGeneratorServiceTest.php b/tests/Feature/Services/RealEstateBalanceGeneratorServiceTest.php new file mode 100644 index 00000000..3195ee02 --- /dev/null +++ b/tests/Feature/Services/RealEstateBalanceGeneratorServiceTest.php @@ -0,0 +1,293 @@ +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()); +}); diff --git a/tests/Feature/Settings/AccountTest.php b/tests/Feature/Settings/AccountTest.php index 7bf4e00f..69acced2 100644 --- a/tests/Feature/Settings/AccountTest.php +++ b/tests/Feature/Settings/AccountTest.php @@ -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) + ); +});