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:
parent
1880333b1c
commit
fa11dc78e0
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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)) {
|
||||
|
|
|
|||
|
|
@ -71,6 +71,7 @@ class StoreAccountRequest extends FormRequest
|
|||
}),
|
||||
],
|
||||
'notes' => ['nullable', 'string', 'max:2000'],
|
||||
'revaluation_percentage' => ['nullable', 'numeric', 'min:-100', 'max:100'],
|
||||
]);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -46,6 +46,7 @@ class UpdateRealEstateDetailRequest extends FormRequest
|
|||
}),
|
||||
],
|
||||
'notes' => ['nullable', 'string', 'max:2000'],
|
||||
'revaluation_percentage' => ['nullable', 'numeric', 'min:-100', 'max:100'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
];
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
];
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -185,6 +185,9 @@ export function CreateAccountDialog({
|
|||
.linkedLoanAccountId,
|
||||
notes:
|
||||
formDataRef.current.realEstate.notes || null,
|
||||
revaluation_percentage:
|
||||
formDataRef.current.realEstate
|
||||
.revaluationPercentage || null,
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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 && (
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
@ -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,
|
||||
]);
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue