feat(accounts): add market value and annual revaluation to real estate accounts (#245)

## Summary

- Allow setting the **current market value** (balance) when creating a
real estate account — the balance field existed for other account types
but was hidden for real estate
- Add an **annual revaluation percentage** field to real estate
accounts, editable from both the creation form and the property details
card on the account show page
- Create a scheduled command (`real-estate:apply-revaluation`) that runs
monthly on the 1st to automatically adjust property values by
`revaluation_percentage / 12 / 100`

## Details

### Backend
- Migration adds `decimal('revaluation_percentage', 5, 2)` nullable
column to `real_estate_details`
- Validation: nullable numeric, range -100 to +100 (negative for
depreciation)
- `ApplyRealEstateRevaluationCommand` finds all accounts with a
non-null/non-zero percentage, gets the latest balance, and upserts the
new balance for today
- Scheduled `->monthlyOn(1, '00:00')` in `routes/console.php`

### Frontend
- Added `real_estate` to `BALANCE_ACCOUNT_TYPES` so the Market Value
field appears during creation
- Added Annual Revaluation (%) input to both the creation form and the
property details card
- Read-mode displays formatted percentage with sign (e.g.,
"+3.50%/year")

### Tests
- 6 new tests in `RealEstateTest.php` covering creation with balance,
revaluation %, negative %, validation, PATCH update, and clearing to
null
- 8 tests in `ApplyRealEstateRevaluationTest.php` covering
positive/negative revaluation, skip conditions, latest balance usage,
multiple accounts, and upsert behavior
This commit is contained in:
Víctor Falcón 2026-03-26 10:02:20 +00:00 committed by GitHub
parent 1880333b1c
commit fa11dc78e0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 633 additions and 0 deletions

View File

@ -0,0 +1,72 @@
<?php
namespace App\Console\Commands;
use App\Models\RealEstateDetail;
use Illuminate\Console\Command;
class ApplyRealEstateRevaluationCommand extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'real-estate:apply-revaluation';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Apply monthly revaluation to real estate accounts based on their annual revaluation percentage';
/**
* Execute the console command.
*/
public function handle(): int
{
$details = RealEstateDetail::query()
->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;
}
}

View File

@ -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)) {

View File

@ -71,6 +71,7 @@ class StoreAccountRequest extends FormRequest
}),
],
'notes' => ['nullable', 'string', 'max:2000'],
'revaluation_percentage' => ['nullable', 'numeric', 'min:-100', 'max:100'],
]);
}

View File

@ -46,6 +46,7 @@ class UpdateRealEstateDetailRequest extends FormRequest
}),
],
'notes' => ['nullable', 'string', 'max:2000'],
'revaluation_percentage' => ['nullable', 'numeric', 'min:-100', 'max:100'],
];
}
}

View File

@ -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',
];
}

View File

@ -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),
];
}

View File

@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('real_estate_details', function (Blueprint $table) {
$table->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');
});
}
};

View File

@ -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",

View File

@ -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}
/>
</div>
<div className="space-y-2">
<Label htmlFor="revaluation_percentage">
{__('Annual Revaluation (%)')}
</Label>
<Input
id="revaluation_percentage"
type="number"
className="mt-1"
value={realEstateData.revaluationPercentage}
onChange={(e) =>
setRealEstateData((prev) => ({
...prev,
revaluationPercentage: e.target.value,
}))
}
placeholder="0.00"
min="-100"
max="100"
step="0.01"
/>
<p className="pl-1 text-xs text-muted-foreground">
{__(
'Annual percentage applied monthly. Use negative values for depreciation.',
)}
</p>
</div>
</>
)}
</>

View File

@ -185,6 +185,9 @@ export function CreateAccountDialog({
.linkedLoanAccountId,
notes:
formDataRef.current.realEstate.notes || null,
revaluation_percentage:
formDataRef.current.realEstate
.revaluationPercentage || null,
}
: {}),
},

View File

@ -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({
/>
</div>
<div className="space-y-2">
<Label htmlFor="edit_revaluation_percentage">
{__('Annual Revaluation (%)')}
</Label>
<Input
id="edit_revaluation_percentage"
type="number"
value={formData.revaluation_percentage}
onChange={(e) =>
setFormData((prev) => ({
...prev,
revaluation_percentage: e.target.value,
}))
}
placeholder="0.00"
min="-100"
max="100"
step="0.01"
/>
<p className="text-xs text-muted-foreground">
{__(
'Annual percentage applied monthly. Use negative values for depreciation.',
)}
</p>
</div>
<div className="flex justify-end gap-2">
<Button
type="button"
@ -904,6 +939,20 @@ function PropertyDetailsCard({
</dd>
</div>
)}
{detail.revaluation_percentage != null && (
<div>
<dt className="text-sm text-muted-foreground">
{__('Annual Revaluation')}
</dt>
<dd className="font-medium">
{Number(detail.revaluation_percentage) > 0
? '+'
: ''}
{detail.revaluation_percentage}%{__('/year')}
</dd>
</div>
)}
</dl>
{detail.notes && (

View File

@ -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;

View File

@ -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');

View File

@ -0,0 +1,285 @@
<?php
use App\Models\Account;
use App\Models\AccountBalance;
use App\Models\RealEstateDetail;
use App\Models\User;
use function Pest\Laravel\artisan;
beforeEach(function () {
$this->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);
});

View File

@ -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,
]);
});