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:
parent
5b78509588
commit
094fb1b744
|
|
@ -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());
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
|
@ -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([
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
});
|
||||
|
|
@ -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)
|
||||
);
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue