diff --git a/app/Console/Commands/ApplyRealEstateRevaluationCommand.php b/app/Console/Commands/ApplyRealEstateRevaluationCommand.php new file mode 100644 index 00000000..ddd002e7 --- /dev/null +++ b/app/Console/Commands/ApplyRealEstateRevaluationCommand.php @@ -0,0 +1,72 @@ +whereNotNull('revaluation_percentage') + ->where('revaluation_percentage', '!=', 0) + ->with('account') + ->get(); + + $applied = 0; + $skipped = 0; + + foreach ($details as $detail) { + $account = $detail->account; + + if (! $account) { + $skipped++; + + continue; + } + + $latestBalance = $account->balances() + ->orderByDesc('balance_date') + ->first(); + + if (! $latestBalance) { + $skipped++; + + continue; + } + + $monthlyRate = (float) $detail->revaluation_percentage / 12 / 100; + $newBalance = (int) round($latestBalance->balance * (1 + $monthlyRate)); + + $account->balances()->updateOrCreate( + ['balance_date' => now()->toDateString()], + ['balance' => $newBalance], + ); + + $applied++; + } + + $this->info("Revaluation applied to {$applied} account(s), skipped {$skipped}."); + + return self::SUCCESS; + } +} diff --git a/app/Http/Controllers/Settings/AccountController.php b/app/Http/Controllers/Settings/AccountController.php index 1b8762c6..f0ec307e 100644 --- a/app/Http/Controllers/Settings/AccountController.php +++ b/app/Http/Controllers/Settings/AccountController.php @@ -64,6 +64,7 @@ class AccountController extends Controller $realEstateData = collect($validated)->only([ 'property_type', 'address', 'purchase_price', 'purchase_date', 'area_value', 'area_unit', 'linked_loan_account_id', 'notes', + 'revaluation_percentage', ])->filter(fn ($value) => $value !== null)->toArray(); if (! empty($realEstateData)) { diff --git a/app/Http/Requests/Settings/StoreAccountRequest.php b/app/Http/Requests/Settings/StoreAccountRequest.php index a9d4ef46..d977ed16 100644 --- a/app/Http/Requests/Settings/StoreAccountRequest.php +++ b/app/Http/Requests/Settings/StoreAccountRequest.php @@ -71,6 +71,7 @@ class StoreAccountRequest extends FormRequest }), ], 'notes' => ['nullable', 'string', 'max:2000'], + 'revaluation_percentage' => ['nullable', 'numeric', 'min:-100', 'max:100'], ]); } diff --git a/app/Http/Requests/UpdateRealEstateDetailRequest.php b/app/Http/Requests/UpdateRealEstateDetailRequest.php index 49afbeb5..fc7ba89f 100644 --- a/app/Http/Requests/UpdateRealEstateDetailRequest.php +++ b/app/Http/Requests/UpdateRealEstateDetailRequest.php @@ -46,6 +46,7 @@ class UpdateRealEstateDetailRequest extends FormRequest }), ], 'notes' => ['nullable', 'string', 'max:2000'], + 'revaluation_percentage' => ['nullable', 'numeric', 'min:-100', 'max:100'], ]; } } diff --git a/app/Models/RealEstateDetail.php b/app/Models/RealEstateDetail.php index 3e727100..e87fb6ed 100644 --- a/app/Models/RealEstateDetail.php +++ b/app/Models/RealEstateDetail.php @@ -12,6 +12,7 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo; /** * @property PropertyType $property_type * @property int|null $purchase_price + * @property string|null $revaluation_percentage */ class RealEstateDetail extends Model { @@ -28,6 +29,7 @@ class RealEstateDetail extends Model 'area_value', 'area_unit', 'notes', + 'revaluation_percentage', ]; protected function casts(): array @@ -37,6 +39,7 @@ class RealEstateDetail extends Model 'purchase_price' => 'integer', 'purchase_date' => 'date', 'area_value' => 'decimal:2', + 'revaluation_percentage' => 'decimal:2', ]; } diff --git a/database/factories/RealEstateDetailFactory.php b/database/factories/RealEstateDetailFactory.php index e9ebb894..e936ae30 100644 --- a/database/factories/RealEstateDetailFactory.php +++ b/database/factories/RealEstateDetailFactory.php @@ -29,6 +29,7 @@ class RealEstateDetailFactory extends Factory 'area_value' => fake()->randomFloat(2, 50, 500), 'area_unit' => fake()->randomElement(['sqm', 'sqft']), 'notes' => fake()->optional()->sentence(), + 'revaluation_percentage' => fake()->optional()->randomFloat(2, -5, 10), ]; } diff --git a/database/migrations/2026_03_25_125717_add_revaluation_percentage_to_real_estate_details_table.php b/database/migrations/2026_03_25_125717_add_revaluation_percentage_to_real_estate_details_table.php new file mode 100644 index 00000000..28479066 --- /dev/null +++ b/database/migrations/2026_03_25_125717_add_revaluation_percentage_to_real_estate_details_table.php @@ -0,0 +1,28 @@ +decimal('revaluation_percentage', 5, 2)->nullable()->after('notes'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('real_estate_details', function (Blueprint $table) { + $table->dropColumn('revaluation_percentage'); + }); + } +}; diff --git a/lang/es.json b/lang/es.json index 086bd2d4..99250148 100644 --- a/lang/es.json +++ b/lang/es.json @@ -27,6 +27,7 @@ "...and 40+ more": "...y m\u00e1s de 40", "/12 min": "/12 min", "/month": "/mes", + "/year": "/año", "1,200+ users": "1.200+ usuarios", "1. Data Controller": "1. Controlador de Datos", "10. Children's Privacy": "10. Privacidad de los Ni\u00f1os", @@ -152,6 +153,9 @@ "Amount is required": "Se requiere un valor", "An unexpected error occurred during sync.": "Se produjo un error inesperado durante la sincronizaci\u00f3n.", "Annual": "Anual", + "Annual Revaluation": "Revalorización anual", + "Annual Revaluation (%)": "Revalorización anual (%)", + "Annual percentage applied monthly. Use negative values for depreciation.": "Porcentaje anual aplicado mensualmente. Usa valores negativos para depreciación.", "Any bugs, viruses, or similar harmful components transmitted through the service": "Cualquier error, virus o componente da\u00f1ino similar transmitido a trav\u00e9s del servicio", "Any bugs, viruses, or similar harmful\\n components transmitted through the service": "Cualquier error, virus u otros componentes da\u00f1inos transmitidos a trav\u00e9s del servicio", "Any errors or omissions in content or any loss or damage incurred from using content": "Cualquier error u omisi\u00f3n en el contenido o cualquier p\u00e9rdida o da\u00f1o incurrido por usar el contenido", diff --git a/resources/js/components/accounts/account-form.tsx b/resources/js/components/accounts/account-form.tsx index 6e50bb10..d15ec2a7 100644 --- a/resources/js/components/accounts/account-form.tsx +++ b/resources/js/components/accounts/account-form.tsx @@ -33,6 +33,7 @@ import { CustomBankData, CustomBankForm } from './custom-bank-form'; const BALANCE_ACCOUNT_TYPES: AccountType[] = [ 'investment', 'loan', + 'real_estate', 'retirement', 'savings', ]; @@ -46,6 +47,7 @@ export interface RealEstateFormData { areaUnit: AreaUnit | null; linkedLoanAccountId: string | null; notes: string; + revaluationPercentage: string; } export interface AccountFormData { @@ -86,6 +88,7 @@ const initialRealEstateData: RealEstateFormData = { areaUnit: null, linkedLoanAccountId: null, notes: '', + revaluationPercentage: '', }; export function AccountForm({ @@ -522,6 +525,33 @@ export function AccountForm({ rows={3} /> + +
+ + + setRealEstateData((prev) => ({ + ...prev, + revaluationPercentage: e.target.value, + })) + } + placeholder="0.00" + min="-100" + max="100" + step="0.01" + /> +

+ {__( + 'Annual percentage applied monthly. Use negative values for depreciation.', + )} +

+
)} diff --git a/resources/js/components/accounts/create-account-dialog.tsx b/resources/js/components/accounts/create-account-dialog.tsx index 030d68c1..bd7f6ad3 100644 --- a/resources/js/components/accounts/create-account-dialog.tsx +++ b/resources/js/components/accounts/create-account-dialog.tsx @@ -185,6 +185,9 @@ export function CreateAccountDialog({ .linkedLoanAccountId, notes: formDataRef.current.realEstate.notes || null, + revaluation_percentage: + formDataRef.current.realEstate + .revaluationPercentage || null, } : {}), }, diff --git a/resources/js/pages/Accounts/Show.tsx b/resources/js/pages/Accounts/Show.tsx index 1b3407fa..4fa68979 100644 --- a/resources/js/pages/Accounts/Show.tsx +++ b/resources/js/pages/Accounts/Show.tsx @@ -553,6 +553,10 @@ function PropertyDetailsCard({ linked_loan_account_id: detail.linked_loan_account_id ?? (null as string | null), notes: detail.notes ?? '', + revaluation_percentage: + detail.revaluation_percentage != null + ? String(detail.revaluation_percentage) + : '', }); function handleSubmit(e: React.FormEvent) { @@ -570,6 +574,7 @@ function PropertyDetailsCard({ area_unit: formData.area_unit, linked_loan_account_id: formData.linked_loan_account_id, notes: formData.notes || null, + revaluation_percentage: formData.revaluation_percentage || null, }, { preserveScroll: true, @@ -589,6 +594,10 @@ function PropertyDetailsCard({ area_unit: detail.area_unit ?? null, linked_loan_account_id: detail.linked_loan_account_id ?? null, notes: detail.notes ?? '', + revaluation_percentage: + detail.revaluation_percentage != null + ? String(detail.revaluation_percentage) + : '', }); onEditToggle(false); } @@ -795,6 +804,32 @@ function PropertyDetailsCard({ /> +
+ + + setFormData((prev) => ({ + ...prev, + revaluation_percentage: e.target.value, + })) + } + placeholder="0.00" + min="-100" + max="100" + step="0.01" + /> +

+ {__( + 'Annual percentage applied monthly. Use negative values for depreciation.', + )} +

+
+
)} + + {detail.revaluation_percentage != null && ( +
+
+ {__('Annual Revaluation')} +
+
+ {Number(detail.revaluation_percentage) > 0 + ? '+' + : ''} + {detail.revaluation_percentage}%{__('/year')} +
+
+ )} {detail.notes && ( diff --git a/resources/js/types/account.ts b/resources/js/types/account.ts index 21a1d687..878ab229 100644 --- a/resources/js/types/account.ts +++ b/resources/js/types/account.ts @@ -94,6 +94,7 @@ export interface RealEstateDetail { area_value: string | null; area_unit: AreaUnit | null; notes: string | null; + revaluation_percentage: number | null; linked_loan_account_id: UUID | null; linked_loan_account: Account | null; current_market_value: number | null; diff --git a/routes/console.php b/routes/console.php index d860e9bc..a3e3fc37 100644 --- a/routes/console.php +++ b/routes/console.php @@ -7,3 +7,4 @@ Schedule::command('horizon:snapshot')->everyFiveMinutes(); Schedule::command('budgets:generate-periods')->daily(); Schedule::command('banking:sync')->everySixHours(); Schedule::command('banks:check-logos')->weekly(); +Schedule::command('real-estate:apply-revaluation')->monthlyOn(1, '00:00'); diff --git a/tests/Feature/ApplyRealEstateRevaluationTest.php b/tests/Feature/ApplyRealEstateRevaluationTest.php new file mode 100644 index 00000000..5e63fbbe --- /dev/null +++ b/tests/Feature/ApplyRealEstateRevaluationTest.php @@ -0,0 +1,285 @@ +user = User::factory()->onboarded()->create(); +}); + +// ------------------------------------------------------------------- +// Applying revaluation to accounts with positive percentage +// ------------------------------------------------------------------- + +it('applies positive annual revaluation monthly', function () { + $account = Account::factory()->realEstate()->create([ + 'user_id' => $this->user->id, + ]); + + RealEstateDetail::factory()->create([ + 'account_id' => $account->id, + 'revaluation_percentage' => 6.00, // 6% annual = 0.5% monthly + ]); + + AccountBalance::factory()->create([ + 'account_id' => $account->id, + 'balance_date' => now()->subMonth()->toDateString(), + 'balance' => 10000000, // 100,000.00 + ]); + + artisan('real-estate:apply-revaluation')->assertSuccessful(); + + $newBalance = AccountBalance::query() + ->where('account_id', $account->id) + ->where('balance_date', now()->toDateString()) + ->first(); + + expect($newBalance)->not->toBeNull(); + // 10,000,000 * (1 + 6/12/100) = 10,000,000 * 1.005 = 10,050,000 + expect($newBalance->balance)->toBe(10050000); +}); + +// ------------------------------------------------------------------- +// Applying revaluation with negative percentage (depreciation) +// ------------------------------------------------------------------- + +it('applies negative annual revaluation monthly', function () { + $account = Account::factory()->realEstate()->create([ + 'user_id' => $this->user->id, + ]); + + RealEstateDetail::factory()->create([ + 'account_id' => $account->id, + 'revaluation_percentage' => -12.00, // -12% annual = -1% monthly + ]); + + AccountBalance::factory()->create([ + 'account_id' => $account->id, + 'balance_date' => now()->subMonth()->toDateString(), + 'balance' => 20000000, // 200,000.00 + ]); + + artisan('real-estate:apply-revaluation')->assertSuccessful(); + + $newBalance = AccountBalance::query() + ->where('account_id', $account->id) + ->where('balance_date', now()->toDateString()) + ->first(); + + expect($newBalance)->not->toBeNull(); + // 20,000,000 * (1 + (-12)/12/100) = 20,000,000 * 0.99 = 19,800,000 + expect($newBalance->balance)->toBe(19800000); +}); + +// ------------------------------------------------------------------- +// Skipping accounts without balance +// ------------------------------------------------------------------- + +it('skips accounts that have no existing balance', function () { + $account = Account::factory()->realEstate()->create([ + 'user_id' => $this->user->id, + ]); + + RealEstateDetail::factory()->create([ + 'account_id' => $account->id, + 'revaluation_percentage' => 5.00, + ]); + + // No balance created + + artisan('real-estate:apply-revaluation')->assertSuccessful(); + + expect(AccountBalance::where('account_id', $account->id)->count())->toBe(0); +}); + +// ------------------------------------------------------------------- +// Skipping accounts with null revaluation percentage +// ------------------------------------------------------------------- + +it('skips accounts with null revaluation percentage', function () { + $account = Account::factory()->realEstate()->create([ + 'user_id' => $this->user->id, + ]); + + RealEstateDetail::factory()->create([ + 'account_id' => $account->id, + 'revaluation_percentage' => null, + ]); + + AccountBalance::factory()->create([ + 'account_id' => $account->id, + 'balance_date' => now()->subMonth()->toDateString(), + 'balance' => 10000000, + ]); + + artisan('real-estate:apply-revaluation')->assertSuccessful(); + + // Should not create a new balance for today + expect( + AccountBalance::query() + ->where('account_id', $account->id) + ->where('balance_date', now()->toDateString()) + ->exists() + )->toBeFalse(); +}); + +// ------------------------------------------------------------------- +// Skipping accounts with zero revaluation percentage +// ------------------------------------------------------------------- + +it('skips accounts with zero revaluation percentage', function () { + $account = Account::factory()->realEstate()->create([ + 'user_id' => $this->user->id, + ]); + + RealEstateDetail::factory()->create([ + 'account_id' => $account->id, + 'revaluation_percentage' => 0, + ]); + + AccountBalance::factory()->create([ + 'account_id' => $account->id, + 'balance_date' => now()->subMonth()->toDateString(), + 'balance' => 10000000, + ]); + + artisan('real-estate:apply-revaluation')->assertSuccessful(); + + expect( + AccountBalance::query() + ->where('account_id', $account->id) + ->where('balance_date', now()->toDateString()) + ->exists() + )->toBeFalse(); +}); + +// ------------------------------------------------------------------- +// Uses the latest balance as the basis for revaluation +// ------------------------------------------------------------------- + +it('uses the latest balance for revaluation calculation', function () { + $account = Account::factory()->realEstate()->create([ + 'user_id' => $this->user->id, + ]); + + RealEstateDetail::factory()->create([ + 'account_id' => $account->id, + 'revaluation_percentage' => 12.00, // 1% monthly + ]); + + // Older balance + AccountBalance::factory()->create([ + 'account_id' => $account->id, + 'balance_date' => now()->subMonths(3)->toDateString(), + 'balance' => 5000000, + ]); + + // More recent balance — this should be used + AccountBalance::factory()->create([ + 'account_id' => $account->id, + 'balance_date' => now()->subMonth()->toDateString(), + 'balance' => 10000000, + ]); + + artisan('real-estate:apply-revaluation')->assertSuccessful(); + + $newBalance = AccountBalance::query() + ->where('account_id', $account->id) + ->where('balance_date', now()->toDateString()) + ->first(); + + // Should use 10,000,000 not 5,000,000 + // 10,000,000 * 1.01 = 10,100,000 + expect($newBalance->balance)->toBe(10100000); +}); + +// ------------------------------------------------------------------- +// Processes multiple accounts +// ------------------------------------------------------------------- + +it('processes multiple real estate accounts', function () { + $account1 = Account::factory()->realEstate()->create([ + 'user_id' => $this->user->id, + ]); + RealEstateDetail::factory()->create([ + 'account_id' => $account1->id, + 'revaluation_percentage' => 12.00, + ]); + AccountBalance::factory()->create([ + 'account_id' => $account1->id, + 'balance_date' => now()->subMonth()->toDateString(), + 'balance' => 10000000, + ]); + + $user2 = User::factory()->onboarded()->create(); + $account2 = Account::factory()->realEstate()->create([ + 'user_id' => $user2->id, + ]); + RealEstateDetail::factory()->create([ + 'account_id' => $account2->id, + 'revaluation_percentage' => 6.00, + ]); + AccountBalance::factory()->create([ + 'account_id' => $account2->id, + 'balance_date' => now()->subMonth()->toDateString(), + 'balance' => 20000000, + ]); + + artisan('real-estate:apply-revaluation')->assertSuccessful(); + + $balance1 = AccountBalance::query() + ->where('account_id', $account1->id) + ->where('balance_date', now()->toDateString()) + ->first(); + + $balance2 = AccountBalance::query() + ->where('account_id', $account2->id) + ->where('balance_date', now()->toDateString()) + ->first(); + + expect($balance1->balance)->toBe(10100000); // 10M * 1.01 + expect($balance2->balance)->toBe(20100000); // 20M * 1.005 +}); + +// ------------------------------------------------------------------- +// Upserts balance for today if one already exists +// ------------------------------------------------------------------- + +it('updates existing balance for today instead of creating duplicate', function () { + $account = Account::factory()->realEstate()->create([ + 'user_id' => $this->user->id, + ]); + + RealEstateDetail::factory()->create([ + 'account_id' => $account->id, + 'revaluation_percentage' => 12.00, + ]); + + AccountBalance::factory()->create([ + 'account_id' => $account->id, + 'balance_date' => now()->toDateString(), + 'balance' => 10000000, + ]); + + artisan('real-estate:apply-revaluation')->assertSuccessful(); + + // Should have exactly one balance for today (upserted, not duplicated) + expect( + AccountBalance::query() + ->where('account_id', $account->id) + ->where('balance_date', now()->toDateString()) + ->count() + )->toBe(1); + + $balance = AccountBalance::query() + ->where('account_id', $account->id) + ->where('balance_date', now()->toDateString()) + ->first(); + + // 10,000,000 * 1.01 = 10,100,000 + expect($balance->balance)->toBe(10100000); +}); diff --git a/tests/Feature/RealEstateTest.php b/tests/Feature/RealEstateTest.php index 0ec859bd..7d7c4f90 100644 --- a/tests/Feature/RealEstateTest.php +++ b/tests/Feature/RealEstateTest.php @@ -592,3 +592,156 @@ it('preserves real estate detail when account is soft deleted', function () { // 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, + ]); +});