whisper-money/tests/Feature/RealEstateTest.php

1128 lines
35 KiB
PHP

<?php
use App\Enums\AccountType;
use App\Enums\PropertyType;
use App\Jobs\GenerateHistoricalRealEstateBalancesJob;
use App\Models\Account;
use App\Models\Bank;
use App\Models\RealEstateDetail;
use App\Models\User;
use function Pest\Laravel\actingAs;
use function Pest\Laravel\assertDatabaseHas;
beforeEach(function () {
$this->user = User::factory()->onboarded()->create();
$this->bank = Bank::factory()->create();
});
// -------------------------------------------------------------------
// Creating real estate accounts via Settings\AccountController@store
// -------------------------------------------------------------------
it('can create a real estate account with property details', function () {
actingAs($this->user);
$data = [
'name' => 'My Apartment',
'currency_code' => 'EUR',
'type' => AccountType::RealEstate->value,
'property_type' => PropertyType::Residential->value,
'address' => '123 Main St, Madrid',
'purchase_price' => 25000000, // 250,000.00 in cents
'purchase_date' => '2023-06-15',
'area_value' => 120.50,
'area_unit' => 'sqm',
'notes' => 'First floor, two bedrooms',
];
$response = $this->post(route('accounts.store'), $data);
$response->assertRedirect();
assertDatabaseHas('accounts', [
'user_id' => $this->user->id,
'name' => 'My Apartment',
'type' => AccountType::RealEstate->value,
'currency_code' => 'EUR',
'bank_id' => null,
]);
$account = Account::query()
->where('user_id', $this->user->id)
->where('type', AccountType::RealEstate->value)
->first();
assertDatabaseHas('real_estate_details', [
'account_id' => $account->id,
'property_type' => PropertyType::Residential->value,
'address' => '123 Main St, Madrid',
'purchase_price' => 25000000,
'area_unit' => 'sqm',
'notes' => 'First floor, two bedrooms',
]);
});
it('rejects a purchase date before the 1900 floor', function () {
actingAs($this->user);
// A pre-1900 (typically mistyped) date would make the historical balance
// generator build a multi-century monthly series and OOM the queue worker
// (PHP-LARAVEL-49). It must be rejected at validation.
$data = [
'name' => 'My Apartment',
'currency_code' => 'EUR',
'type' => AccountType::RealEstate->value,
'property_type' => PropertyType::Residential->value,
'purchase_price' => 25000000,
'purchase_date' => '1899-12-31',
];
$response = $this->post(route('accounts.store'), $data);
$response->assertSessionHasErrors(['purchase_date']);
});
it('can create a real estate account with only required fields', function () {
actingAs($this->user);
$data = [
'name' => 'Vacant Lot',
'currency_code' => 'USD',
'type' => AccountType::RealEstate->value,
'property_type' => PropertyType::Land->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();
assertDatabaseHas('real_estate_details', [
'account_id' => $account->id,
'property_type' => PropertyType::Land->value,
]);
});
it('requires property_type when creating a real estate account', function () {
actingAs($this->user);
$data = [
'name' => 'My House',
'currency_code' => 'USD',
'type' => AccountType::RealEstate->value,
// property_type is missing
];
$response = $this->post(route('accounts.store'), $data);
$response->assertSessionHasErrors(['property_type']);
});
it('validates property_type must be a valid enum value', function () {
actingAs($this->user);
$data = [
'name' => 'My House',
'currency_code' => 'USD',
'type' => AccountType::RealEstate->value,
'property_type' => 'castle',
];
$response = $this->post(route('accounts.store'), $data);
$response->assertSessionHasErrors(['property_type']);
});
it('can create a real estate account with a linked loan', function () {
actingAs($this->user);
$loanAccount = Account::factory()->create([
'user_id' => $this->user->id,
'bank_id' => $this->bank->id,
'type' => AccountType::Loan,
]);
$data = [
'name' => 'House with Mortgage',
'currency_code' => 'EUR',
'type' => AccountType::RealEstate->value,
'property_type' => PropertyType::Residential->value,
'linked_loan_account_id' => $loanAccount->id,
];
$response = $this->post(route('accounts.store'), $data);
$response->assertRedirect();
$account = Account::query()
->where('user_id', $this->user->id)
->where('type', AccountType::RealEstate->value)
->first();
assertDatabaseHas('real_estate_details', [
'account_id' => $account->id,
'linked_loan_account_id' => $loanAccount->id,
]);
});
it('validates linked_loan_account_id must be a loan account owned by the user', function () {
actingAs($this->user);
// Non-loan account owned by user
$checkingAccount = Account::factory()->create([
'user_id' => $this->user->id,
'type' => AccountType::Checking,
]);
$data = [
'name' => 'My House',
'currency_code' => 'USD',
'type' => AccountType::RealEstate->value,
'property_type' => PropertyType::Residential->value,
'linked_loan_account_id' => $checkingAccount->id,
];
$response = $this->post(route('accounts.store'), $data);
$response->assertSessionHasErrors(['linked_loan_account_id']);
});
it('validates linked_loan_account_id cannot be another users loan', function () {
actingAs($this->user);
$otherUser = User::factory()->create();
$otherLoan = Account::factory()->create([
'user_id' => $otherUser->id,
'type' => AccountType::Loan,
]);
$data = [
'name' => 'My House',
'currency_code' => 'USD',
'type' => AccountType::RealEstate->value,
'property_type' => PropertyType::Residential->value,
'linked_loan_account_id' => $otherLoan->id,
];
$response = $this->post(route('accounts.store'), $data);
$response->assertSessionHasErrors(['linked_loan_account_id']);
});
it('does not require property_type for non-real-estate accounts', function () {
actingAs($this->user);
$data = [
'name' => 'Checking Account',
'bank_id' => $this->bank->id,
'currency_code' => 'USD',
'type' => AccountType::Checking->value,
];
$response = $this->post(route('accounts.store'), $data);
$response->assertRedirect();
});
it('does not require bank_id for non-real-estate account types', function () {
actingAs($this->user);
$data = [
'name' => 'Checking Account',
'currency_code' => 'USD',
'type' => AccountType::Checking->value,
// bank_id intentionally omitted
];
$response = $this->post(route('accounts.store'), $data);
$response->assertSessionMissing('errors');
});
// -------------------------------------------------------------------
// Account show page loads real estate data
// -------------------------------------------------------------------
it('loads real estate detail on account show page', function () {
$this->withoutVite();
actingAs($this->user);
$account = Account::factory()->realEstate()->create([
'user_id' => $this->user->id,
]);
RealEstateDetail::factory()->create([
'account_id' => $account->id,
'property_type' => PropertyType::Residential,
'address' => '456 Oak Ave',
]);
$response = $this->get(route('accounts.show', $account));
$response->assertOk()
->assertInertia(fn ($page) => $page
->component('Accounts/Show')
->has('account.real_estate_detail')
->where('account.real_estate_detail.property_type', PropertyType::Residential->value)
->where('account.real_estate_detail.address', '456 Oak Ave')
);
});
it('loads available loan accounts on real estate show page', function () {
$this->withoutVite();
actingAs($this->user);
$account = Account::factory()->realEstate()->create([
'user_id' => $this->user->id,
]);
RealEstateDetail::factory()->create([
'account_id' => $account->id,
]);
$loanAccount = Account::factory()->create([
'user_id' => $this->user->id,
'type' => AccountType::Loan,
]);
// Another user's loan should not be available
Account::factory()->create([
'user_id' => User::factory()->create()->id,
'type' => AccountType::Loan,
]);
$response = $this->get(route('accounts.show', $account));
$response->assertOk()
->assertInertia(fn ($page) => $page
->component('Accounts/Show')
->has('account.available_loan_accounts', 1)
->where('account.available_loan_accounts.0.id', $loanAccount->id)
);
});
it('loads linked loan account with bank info on show page', function () {
$this->withoutVite();
actingAs($this->user);
$loanBank = Bank::factory()->create(['name' => 'Mortgage Bank']);
$loanAccount = Account::factory()->create([
'user_id' => $this->user->id,
'bank_id' => $loanBank->id,
'type' => AccountType::Loan,
]);
$account = Account::factory()->realEstate()->create([
'user_id' => $this->user->id,
]);
RealEstateDetail::factory()->create([
'account_id' => $account->id,
'linked_loan_account_id' => $loanAccount->id,
]);
$response = $this->get(route('accounts.show', $account));
$response->assertOk()
->assertInertia(fn ($page) => $page
->component('Accounts/Show')
->has('account.real_estate_detail.linked_loan_account')
->where('account.real_estate_detail.linked_loan_account.id', $loanAccount->id)
->where('account.real_estate_detail.linked_loan_account.bank.name', 'Mortgage Bank')
);
});
it('does not load real estate data for non-real-estate accounts', function () {
$this->withoutVite();
actingAs($this->user);
$account = Account::factory()->create([
'user_id' => $this->user->id,
'type' => AccountType::Checking,
]);
$response = $this->get(route('accounts.show', $account));
$response->assertOk()
->assertInertia(fn ($page) => $page
->component('Accounts/Show')
->missing('account.real_estate_detail')
->missing('account.available_loan_accounts')
);
});
// -------------------------------------------------------------------
// Accounts index includes real estate in ordering
// -------------------------------------------------------------------
it('includes real estate accounts in index ordered correctly', function () {
$this->withoutVite();
actingAs($this->user);
Account::factory()->create([
'user_id' => $this->user->id,
'type' => AccountType::Loan,
'name' => 'Mortgage',
'position' => 2,
]);
Account::factory()->realEstate()->create([
'user_id' => $this->user->id,
'name' => 'Beach House',
'position' => 1,
]);
Account::factory()->create([
'user_id' => $this->user->id,
'type' => AccountType::Checking,
'name' => 'Main Account',
'position' => 0,
]);
$response = $this->get(route('accounts.list'));
$response->assertOk()
->assertInertia(fn ($page) => $page
->component('Accounts/Index')
->has('accounts', 3)
->where('accounts.0.type', 'checking')
->where('accounts.1.type', 'real_estate')
->where('accounts.2.type', 'loan')
);
});
// -------------------------------------------------------------------
// Updating real estate details via RealEstateDetailController
// -------------------------------------------------------------------
it('can update real estate detail', function () {
actingAs($this->user);
$account = Account::factory()->realEstate()->create([
'user_id' => $this->user->id,
]);
$detail = RealEstateDetail::factory()->create([
'account_id' => $account->id,
'property_type' => PropertyType::Residential,
'address' => 'Old Address',
]);
$response = $this->patch(route('accounts.real-estate-detail.update', $account), [
'property_type' => PropertyType::Commercial->value,
'address' => 'New Commercial Address',
'purchase_price' => 50000000,
'notes' => 'Updated notes',
]);
$response->assertRedirect(route('accounts.show', $account));
assertDatabaseHas('real_estate_details', [
'id' => $detail->id,
'property_type' => PropertyType::Commercial->value,
'address' => 'New Commercial Address',
'purchase_price' => 50000000,
'notes' => 'Updated notes',
]);
});
it('can link a loan account when updating real estate detail', function () {
actingAs($this->user);
$loanAccount = Account::factory()->create([
'user_id' => $this->user->id,
'type' => AccountType::Loan,
]);
$account = Account::factory()->realEstate()->create([
'user_id' => $this->user->id,
]);
RealEstateDetail::factory()->create([
'account_id' => $account->id,
]);
$response = $this->patch(route('accounts.real-estate-detail.update', $account), [
'linked_loan_account_id' => $loanAccount->id,
]);
$response->assertRedirect(route('accounts.show', $account));
assertDatabaseHas('real_estate_details', [
'account_id' => $account->id,
'linked_loan_account_id' => $loanAccount->id,
]);
});
it('can unlink a loan account by setting null', function () {
actingAs($this->user);
$loanAccount = Account::factory()->create([
'user_id' => $this->user->id,
'type' => AccountType::Loan,
]);
$account = Account::factory()->realEstate()->create([
'user_id' => $this->user->id,
]);
RealEstateDetail::factory()->create([
'account_id' => $account->id,
'linked_loan_account_id' => $loanAccount->id,
]);
$response = $this->patch(route('accounts.real-estate-detail.update', $account), [
'linked_loan_account_id' => null,
]);
$response->assertRedirect(route('accounts.show', $account));
assertDatabaseHas('real_estate_details', [
'account_id' => $account->id,
'linked_loan_account_id' => null,
]);
});
it('validates linked_loan_account_id on update must be users loan', function () {
actingAs($this->user);
$otherUser = User::factory()->create();
$otherLoan = Account::factory()->create([
'user_id' => $otherUser->id,
'type' => AccountType::Loan,
]);
$account = Account::factory()->realEstate()->create([
'user_id' => $this->user->id,
]);
RealEstateDetail::factory()->create([
'account_id' => $account->id,
]);
$response = $this->patch(route('accounts.real-estate-detail.update', $account), [
'linked_loan_account_id' => $otherLoan->id,
]);
$response->assertSessionHasErrors(['linked_loan_account_id']);
});
it('returns 404 when updating real estate detail for account without one', function () {
actingAs($this->user);
$account = Account::factory()->realEstate()->create([
'user_id' => $this->user->id,
]);
// No RealEstateDetail created
$response = $this->patch(route('accounts.real-estate-detail.update', $account), [
'property_type' => PropertyType::Commercial->value,
]);
$response->assertNotFound();
});
// -------------------------------------------------------------------
// IDOR protection for real estate detail updates
// -------------------------------------------------------------------
it('prevents updating another users real estate detail', function () {
actingAs($this->user);
$otherUser = User::factory()->create();
$otherAccount = Account::factory()->realEstate()->create([
'user_id' => $otherUser->id,
]);
RealEstateDetail::factory()->create([
'account_id' => $otherAccount->id,
]);
$response = $this->patch(route('accounts.real-estate-detail.update', $otherAccount), [
'address' => 'Hacked Address',
]);
$response->assertForbidden();
});
// -------------------------------------------------------------------
// Model relationships
// -------------------------------------------------------------------
it('has a one-to-one relationship between account and real estate detail', function () {
$account = Account::factory()->realEstate()->create([
'user_id' => $this->user->id,
]);
$detail = RealEstateDetail::factory()->create([
'account_id' => $account->id,
'property_type' => PropertyType::Vacation,
]);
expect($account->fresh()->realEstateDetail)->not->toBeNull();
expect($account->fresh()->realEstateDetail->id)->toBe($detail->id);
expect($detail->fresh()->account->id)->toBe($account->id);
});
it('can link and access a loan account through real estate detail', function () {
$loanAccount = Account::factory()->create([
'user_id' => $this->user->id,
'type' => AccountType::Loan,
]);
$account = Account::factory()->realEstate()->create([
'user_id' => $this->user->id,
]);
$detail = RealEstateDetail::factory()->create([
'account_id' => $account->id,
'linked_loan_account_id' => $loanAccount->id,
]);
expect($detail->fresh()->linkedLoanAccount)->not->toBeNull();
expect($detail->fresh()->linkedLoanAccount->id)->toBe($loanAccount->id);
expect($detail->fresh()->linkedLoanAccount->type)->toBe(AccountType::Loan);
});
// -------------------------------------------------------------------
// Deleting an account cascades to real estate detail
// -------------------------------------------------------------------
it('preserves real estate detail when account is soft deleted', function () {
actingAs($this->user);
$account = Account::factory()->realEstate()->create([
'user_id' => $this->user->id,
]);
$detail = RealEstateDetail::factory()->create([
'account_id' => $account->id,
]);
$this->delete(route('accounts.destroy', $account));
// Account is soft-deleted
expect(Account::find($account->id))->toBeNull();
expect(Account::withTrashed()->find($account->id))->not->toBeNull();
// Real estate detail still exists (FK cascade only applies to hard deletes)
assertDatabaseHas('real_estate_details', ['id' => $detail->id]);
});
// -------------------------------------------------------------------
// Creating real estate accounts with balance and revaluation percentage
// -------------------------------------------------------------------
it('can create a real estate account with initial market value', function () {
actingAs($this->user);
$data = [
'name' => 'My House',
'currency_code' => 'EUR',
'type' => AccountType::RealEstate->value,
'property_type' => PropertyType::Residential->value,
'balance' => 30000000, // 300,000.00 in cents
];
$response = $this->post(route('accounts.store'), $data);
$response->assertRedirect();
$account = Account::query()
->where('user_id', $this->user->id)
->where('type', AccountType::RealEstate->value)
->first();
assertDatabaseHas('account_balances', [
'account_id' => $account->id,
'balance' => 30000000,
'balance_date' => now()->toDateString(),
]);
});
it('can create a real estate account with revaluation percentage', function () {
actingAs($this->user);
$data = [
'name' => 'Appreciating Property',
'currency_code' => 'USD',
'type' => AccountType::RealEstate->value,
'property_type' => PropertyType::Residential->value,
'balance' => 50000000,
'revaluation_percentage' => 3.50,
];
$response = $this->post(route('accounts.store'), $data);
$response->assertRedirect();
$account = Account::query()
->where('user_id', $this->user->id)
->where('type', AccountType::RealEstate->value)
->first();
assertDatabaseHas('real_estate_details', [
'account_id' => $account->id,
'revaluation_percentage' => '3.50',
]);
assertDatabaseHas('account_balances', [
'account_id' => $account->id,
'balance' => 50000000,
]);
});
it('can create a real estate account with negative revaluation percentage', function () {
actingAs($this->user);
$data = [
'name' => 'Depreciating Property',
'currency_code' => 'USD',
'type' => AccountType::RealEstate->value,
'property_type' => PropertyType::Commercial->value,
'revaluation_percentage' => -2.00,
];
$response = $this->post(route('accounts.store'), $data);
$response->assertRedirect();
$account = Account::query()
->where('user_id', $this->user->id)
->where('type', AccountType::RealEstate->value)
->first();
assertDatabaseHas('real_estate_details', [
'account_id' => $account->id,
'revaluation_percentage' => '-2.00',
]);
});
it('validates revaluation percentage is between -100 and 100', function () {
actingAs($this->user);
$data = [
'name' => 'My House',
'currency_code' => 'USD',
'type' => AccountType::RealEstate->value,
'property_type' => PropertyType::Residential->value,
'revaluation_percentage' => 150,
];
$response = $this->post(route('accounts.store'), $data);
$response->assertSessionHasErrors(['revaluation_percentage']);
});
it('can update revaluation percentage via real estate detail endpoint', function () {
actingAs($this->user);
$account = Account::factory()->realEstate()->create([
'user_id' => $this->user->id,
]);
$detail = RealEstateDetail::factory()->create([
'account_id' => $account->id,
'revaluation_percentage' => null,
]);
$response = $this->patch(route('accounts.real-estate-detail.update', $account), [
'revaluation_percentage' => 5.25,
]);
$response->assertRedirect(route('accounts.show', $account));
assertDatabaseHas('real_estate_details', [
'id' => $detail->id,
'revaluation_percentage' => '5.25',
]);
});
it('can clear revaluation percentage by setting null', function () {
actingAs($this->user);
$account = Account::factory()->realEstate()->create([
'user_id' => $this->user->id,
]);
RealEstateDetail::factory()->create([
'account_id' => $account->id,
'revaluation_percentage' => 3.50,
]);
$response = $this->patch(route('accounts.real-estate-detail.update', $account), [
'revaluation_percentage' => null,
]);
$response->assertRedirect(route('accounts.show', $account));
assertDatabaseHas('real_estate_details', [
'account_id' => $account->id,
'revaluation_percentage' => null,
]);
});
// -------------------------------------------------------------------
// Show page returns revaluation_percentage and purchase_date correctly
// -------------------------------------------------------------------
it('loads revaluation_percentage and purchase_date on account show page', function () {
$this->withoutVite();
actingAs($this->user);
$account = Account::factory()->realEstate()->create([
'user_id' => $this->user->id,
]);
RealEstateDetail::factory()->create([
'account_id' => $account->id,
'property_type' => PropertyType::Residential,
'purchase_date' => '2023-06-15',
'revaluation_percentage' => 5.25,
]);
$response = $this->get(route('accounts.show', $account));
$response->assertOk()
->assertInertia(fn ($page) => $page
->component('Accounts/Show')
->has('account.real_estate_detail')
->where('account.real_estate_detail.revaluation_percentage', '5.25')
->where('account.real_estate_detail.purchase_date', '2023-06-15')
);
});
// -------------------------------------------------------------------
// Updating real estate accounts via Settings\AccountController@update
// -------------------------------------------------------------------
it('can update real estate details via account update endpoint', function () {
actingAs($this->user);
$account = Account::factory()->realEstate()->create([
'user_id' => $this->user->id,
]);
RealEstateDetail::factory()->create([
'account_id' => $account->id,
'property_type' => PropertyType::Residential,
'address' => 'Old Address',
'purchase_date' => '2020-01-01',
'revaluation_percentage' => 2.00,
]);
$response = $this->patch(route('accounts.update', $account), [
'name' => 'Updated Property',
'currency_code' => 'EUR',
'type' => AccountType::RealEstate->value,
'property_type' => PropertyType::Commercial->value,
'address' => 'New Address',
'purchase_price' => 30000000,
'purchase_date' => '2023-06-15',
'revaluation_percentage' => 4.50,
]);
$response->assertRedirect(route('accounts.index'));
assertDatabaseHas('accounts', [
'id' => $account->id,
'name' => 'Updated Property',
]);
assertDatabaseHas('real_estate_details', [
'account_id' => $account->id,
'property_type' => PropertyType::Commercial->value,
'address' => 'New Address',
'purchase_price' => 30000000,
'revaluation_percentage' => '4.50',
]);
});
it('creates real estate detail via account update if it does not exist', function () {
actingAs($this->user);
$account = Account::factory()->realEstate()->create([
'user_id' => $this->user->id,
]);
// No RealEstateDetail created yet
$response = $this->patch(route('accounts.update', $account), [
'name' => 'New Property',
'currency_code' => 'USD',
'type' => AccountType::RealEstate->value,
'property_type' => PropertyType::Land->value,
'purchase_date' => '2024-01-01',
'revaluation_percentage' => 1.50,
]);
$response->assertRedirect(route('accounts.index'));
assertDatabaseHas('real_estate_details', [
'account_id' => $account->id,
'property_type' => PropertyType::Land->value,
'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);
});