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); });