feat(accounts): add real estate asset tracking (#241)

## Summary

- Adds **real estate** as a new account type (`real_estate`) for
tracking property assets within the existing Accounts page
- Properties store metadata (type, address, purchase price/date, area,
notes) in a dedicated `real_estate_details` table with a one-to-one
relationship to accounts
- Properties can be **linked to a loan account** (mortgage) to
implicitly calculate equity — property value counts as asset (+), linked
loan counts as liability (-)
- Market value is tracked as the account balance and uses "market value"
terminology throughout the UI

## What's included

### Backend
- `PropertyType` enum (Residential, Commercial, Land, Vacation, Other)
- `RealEstateDetail` model, factory, migration, policy
- `StoreRealEstateDetailRequest` and `UpdateRealEstateDetailRequest`
form requests
- `RealEstateDetailController` with `update()` for editing property
details
- Extended `Settings\AccountController@store` to create
`RealEstateDetail` when type is `real_estate`
- Extended `AccountController@show` to load real estate detail, linked
loan with bank info, and available loan accounts
- Extended `AccountController@index` SQL ordering to include
`real_estate`
- Conditional validation rules in `StoreAccountRequest` for real estate
fields

### Frontend
- New `real_estate` type in `account.ts` with `PropertyType`,
`AreaUnit`, `RealEstateDetail` interface, and helper functions
- Conditional real estate fields in `account-form.tsx` (property type,
address, purchase price, purchase date, area, linked loan, notes)
- `PropertyDetailsCard` component in `Accounts/Show.tsx` with view and
inline edit modes
- "Update market value" terminology in balance update buttons for real
estate accounts
- `real_estate` added to account type ordering and groups on the Index
page
- Wayfinder routes regenerated

### Tests
- 22 feature tests covering creation, validation, show page, index
ordering, updating details, IDOR protection, model relationships, and
soft delete behavior
- Unit test updates for `AccountType` enum (`reducesNetWorth`,
`isNonTransactional`)

### Translations
- 32 new Spanish translation strings for all real estate UI

## Design decisions
- Real estate accounts are **non-transactional** (like
investment/retirement) — balance-only tracking for now. Future iteration
will link transactions directly for rental income/expense tracking
- **Single loan per property** via direct FK from
`real_estate_details.linked_loan_account_id`
- No encryption on address/notes fields (opted out per discussion)
- No separate sidebar page — real estate is a grouped section within the
existing Accounts page
This commit is contained in:
Víctor Falcón 2026-03-24 10:21:32 +00:00 committed by GitHub
parent ff62532a55
commit 395c4ad2c3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
49 changed files with 2533 additions and 214 deletions

View File

@ -49,6 +49,6 @@ trait ResolvesFeatures
private function getStringBasedFeatures(): array
{
return ['open-banking', 'account-mapping'];
return ['open-banking', 'account-mapping', 'real-estate'];
}
}

View File

@ -9,6 +9,7 @@ enum AccountType: string
case Investment = 'investment';
case Loan = 'loan';
case Retirement = 'retirement';
case RealEstate = 'real_estate';
case Savings = 'savings';
case Others = 'others';
@ -24,4 +25,12 @@ enum AccountType: string
{
return in_array($this, [self::CreditCard, self::Loan], true);
}
/**
* Whether this account type is non-transactional (balance tracking only).
*/
public function isNonTransactional(): bool
{
return in_array($this, [self::Investment, self::Retirement, self::RealEstate], true);
}
}

View File

@ -0,0 +1,12 @@
<?php
namespace App\Enums;
enum PropertyType: string
{
case Residential = 'residential';
case Commercial = 'commercial';
case Land = 'land';
case Vacation = 'vacation';
case Other = 'other';
}

View File

@ -22,7 +22,7 @@ class AccountController extends Controller
$accounts = Account::query()
->where('user_id', $user->id)
->with('bank:id,name,logo')
->orderByRaw("FIELD(type, 'checking', 'savings', 'investment', 'retirement', 'loan', 'credit_card', 'others')")
->orderByRaw("FIELD(type, 'checking', 'savings', 'investment', 'retirement', 'real_estate', 'loan', 'credit_card', 'others')")
->orderBy('name')
->get(['id', 'name', 'name_iv', 'encrypted', 'bank_id', 'type', 'currency_code', 'banking_connection_id']);
@ -38,8 +38,35 @@ class AccountController extends Controller
$account->load('bank:id,name,logo');
$data = $account->only(['id', 'name', 'name_iv', 'encrypted', 'bank_id', 'type', 'currency_code', 'banking_connection_id', 'bank']);
if ($account->type === \App\Enums\AccountType::RealEstate) {
$account->load('realEstateDetail.linkedLoanAccount.bank:id,name,logo');
$realEstateDetail = $account->realEstateDetail;
if ($realEstateDetail) {
$data['real_estate_detail'] = [
...$realEstateDetail->only([
'id', 'property_type', 'address', 'purchase_price',
'purchase_date', 'area_value', 'area_unit', 'notes',
'linked_loan_account_id',
]),
'linked_loan_account' => $realEstateDetail->linkedLoanAccount
? $realEstateDetail->linkedLoanAccount->only(['id', 'name', 'name_iv', 'encrypted', 'type', 'currency_code', 'bank'])
: null,
];
}
// Provide available loan accounts for linking
$data['available_loan_accounts'] = $request->user()
->accounts()
->where('type', \App\Enums\AccountType::Loan->value)
->with('bank:id,name,logo')
->get(['id', 'name', 'name_iv', 'encrypted', 'bank_id', 'type', 'currency_code']);
}
return Inertia::render('Accounts/Show', [
'account' => $account->only(['id', 'name', 'name_iv', 'encrypted', 'bank_id', 'type', 'currency_code', 'banking_connection_id', 'bank']),
'account' => $data,
]);
}
}

View File

@ -0,0 +1,31 @@
<?php
namespace App\Http\Controllers;
use App\Http\Requests\UpdateRealEstateDetailRequest;
use App\Models\Account;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Http\RedirectResponse;
class RealEstateDetailController extends Controller
{
use AuthorizesRequests;
/**
* Update the real estate detail for an account.
*/
public function update(UpdateRealEstateDetailRequest $request, Account $account): RedirectResponse
{
$this->authorize('update', $account);
$realEstateDetail = $account->realEstateDetail;
if (! $realEstateDetail) {
abort(404);
}
$realEstateDetail->update($request->validated());
return to_route('accounts.show', $account);
}
}

View File

@ -40,10 +40,13 @@ class AccountController extends Controller
$user = auth()->user();
$validated = $request->validated();
$balance = $validated['balance'] ?? null;
unset($validated['balance']);
$accountData = collect($validated)->only([
'name', 'bank_id', 'currency_code', 'type',
])->toArray();
$account = $user->accounts()->create([
...$validated,
...$accountData,
'encrypted' => false,
'name_iv' => null,
]);
@ -55,6 +58,18 @@ class AccountController extends Controller
]);
}
// Create real estate detail if account type is real_estate
if ($account->type === \App\Enums\AccountType::RealEstate) {
$realEstateData = collect($validated)->only([
'property_type', 'address', 'purchase_price', 'purchase_date',
'area_value', 'area_unit', 'linked_loan_account_id', 'notes',
])->filter(fn ($value) => $value !== null)->toArray();
if (! empty($realEstateData)) {
$account->realEstateDetail()->create($realEstateData);
}
}
// Set user's currency_code from first account
if ($user->accounts()->count() === 1) {
$user->update(['currency_code' => $account->currency_code]);

View File

@ -0,0 +1,20 @@
<?php
namespace App\Http\Controllers\Settings;
use App\Http\Controllers\Controller;
use App\Http\Requests\Settings\UpdateNetWorthChartRealEstatePreferenceRequest;
use Illuminate\Http\RedirectResponse;
class NetWorthChartRealEstatePreferenceController extends Controller
{
public function update(UpdateNetWorthChartRealEstatePreferenceRequest $request): RedirectResponse
{
$request->user()->setting()->updateOrCreate(
['user_id' => $request->user()->id],
['include_real_estate_in_net_worth_chart' => $request->boolean('include_real_estate_in_net_worth_chart')]
);
return back();
}
}

View File

@ -19,6 +19,7 @@ class ActivateDevelopmentFeatures
if (app()->isLocal() && $request->user()) {
Feature::for($request->user())->activate('open-banking');
Feature::for($request->user())->activate('account-mapping');
Feature::for($request->user())->activate('real-estate');
}
return $next($request);

View File

@ -86,11 +86,13 @@ class HandleInertiaRequests extends Middleware
],
'chartColorScheme' => $user?->setting?->chart_color_scheme->value ?? 'colorful',
'includeLoansInNetWorthChart' => $user?->setting->include_loans_in_net_worth_chart ?? true,
'includeRealEstateInNetWorthChart' => $user?->setting->include_real_estate_in_net_worth_chart ?? true,
'sidebarOpen' => ! $request->hasCookie('sidebar_state') || $request->cookie('sidebar_state') === 'true',
'features' => [
'cashflow' => true,
'open-banking' => $user ? Feature::for($user)->active('open-banking') : false,
'account-mapping' => $user ? Feature::for($user)->active('account-mapping') : false,
'real-estate' => $user ? Feature::for($user)->active('real-estate') : false,
],
'accounts' => fn () => $user ? $user->accounts()
->with('bank:id,name,logo')

View File

@ -3,9 +3,11 @@
namespace App\Http\Requests\Settings;
use App\Enums\AccountType;
use App\Enums\PropertyType;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
use Laravel\Pennant\Feature;
class StoreAccountRequest extends FormRequest
{
@ -14,6 +16,10 @@ class StoreAccountRequest extends FormRequest
*/
public function authorize(): bool
{
if ($this->input('type') === AccountType::RealEstate->value) {
return Feature::for($this->user())->active('real-estate');
}
return true;
}
@ -24,9 +30,13 @@ class StoreAccountRequest extends FormRequest
*/
public function rules(): array
{
return [
$isRealEstate = $this->input('type') === AccountType::RealEstate->value;
$rules = [
'name' => ['required', 'string'],
'bank_id' => ['required', 'exists:banks,id'],
'bank_id' => $isRealEstate
? ['nullable', 'exists:banks,id']
: ['required', 'exists:banks,id'],
'currency_code' => [
'required',
'string',
@ -39,5 +49,31 @@ class StoreAccountRequest extends FormRequest
],
'balance' => ['nullable', 'integer'],
];
if ($isRealEstate) {
$rules = array_merge($rules, [
'property_type' => [
'required',
'string',
Rule::in(array_map(fn ($type) => $type->value, PropertyType::cases())),
],
'address' => ['nullable', 'string', 'max:500'],
'purchase_price' => ['nullable', 'integer', 'min:0'],
'purchase_date' => ['nullable', 'date', 'before_or_equal:today'],
'area_value' => ['nullable', 'numeric', 'min:0', 'max:99999999.99'],
'area_unit' => ['nullable', 'string', Rule::in(['sqm', 'sqft', 'acres', 'hectares'])],
'linked_loan_account_id' => [
'nullable',
'string',
Rule::exists('accounts', 'id')->where(function ($query) {
$query->where('user_id', $this->user()->id)
->where('type', AccountType::Loan->value);
}),
],
'notes' => ['nullable', 'string', 'max:2000'],
]);
}
return $rules;
}
}

View File

@ -0,0 +1,28 @@
<?php
namespace App\Http\Requests\Settings;
use Illuminate\Foundation\Http\FormRequest;
class UpdateNetWorthChartRealEstatePreferenceRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'include_real_estate_in_net_worth_chart' => ['required', 'boolean'],
];
}
}

View File

@ -0,0 +1,50 @@
<?php
namespace App\Http\Requests;
use App\Enums\AccountType;
use App\Enums\PropertyType;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class StoreRealEstateDetailRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'property_type' => [
'required',
'string',
Rule::in(array_map(fn ($type) => $type->value, PropertyType::cases())),
],
'address' => ['nullable', 'string', 'max:500'],
'purchase_price' => ['nullable', 'integer', 'min:0'],
'purchase_date' => ['nullable', 'date', 'before_or_equal:today'],
'area_value' => ['nullable', 'numeric', 'min:0', 'max:99999999.99'],
'area_unit' => ['nullable', 'string', Rule::in(['sqm', 'sqft', 'acres', 'hectares'])],
'linked_loan_account_id' => [
'nullable',
'string',
Rule::exists('accounts', 'id')->where(function ($query) {
$query->where('user_id', $this->user()->id)
->where('type', AccountType::Loan->value);
}),
],
'notes' => ['nullable', 'string', 'max:2000'],
];
}
}

View File

@ -0,0 +1,51 @@
<?php
namespace App\Http\Requests;
use App\Enums\AccountType;
use App\Enums\PropertyType;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class UpdateRealEstateDetailRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'property_type' => [
'sometimes',
'required',
'string',
Rule::in(array_map(fn ($type) => $type->value, PropertyType::cases())),
],
'address' => ['nullable', 'string', 'max:500'],
'purchase_price' => ['nullable', 'integer', 'min:0'],
'purchase_date' => ['nullable', 'date', 'before_or_equal:today'],
'area_value' => ['nullable', 'numeric', 'min:0', 'max:99999999.99'],
'area_unit' => ['nullable', 'string', Rule::in(['sqm', 'sqft', 'acres', 'hectares'])],
'linked_loan_account_id' => [
'nullable',
'string',
Rule::exists('accounts', 'id')->where(function ($query) {
$query->where('user_id', $this->user()->id)
->where('type', AccountType::Loan->value);
}),
],
'notes' => ['nullable', 'string', 'max:2000'],
];
}
}

View File

@ -8,6 +8,7 @@ use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasOne;
use Illuminate\Database\Eloquent\SoftDeletes;
/**
@ -71,6 +72,12 @@ class Account extends Model
return $this->belongsTo(BankingConnection::class);
}
/** @return HasOne<RealEstateDetail, $this> */
public function realEstateDetail(): HasOne
{
return $this->hasOne(RealEstateDetail::class);
}
public function isConnected(): bool
{
return $this->banking_connection_id !== null;

View File

@ -0,0 +1,53 @@
<?php
namespace App\Models;
use App\Enums\PropertyType;
use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* @property \App\Enums\PropertyType $property_type
* @property int|null $purchase_price
*/
class RealEstateDetail extends Model
{
/** @use HasFactory<\Database\Factories\RealEstateDetailFactory> */
use HasFactory, HasUuids;
protected $fillable = [
'account_id',
'linked_loan_account_id',
'property_type',
'address',
'purchase_price',
'purchase_date',
'area_value',
'area_unit',
'notes',
];
protected function casts(): array
{
return [
'property_type' => PropertyType::class,
'purchase_price' => 'integer',
'purchase_date' => 'date',
'area_value' => 'decimal:2',
];
}
/** @return BelongsTo<Account, $this> */
public function account(): BelongsTo
{
return $this->belongsTo(Account::class);
}
/** @return BelongsTo<Account, $this> */
public function linkedLoanAccount(): BelongsTo
{
return $this->belongsTo(Account::class, 'linked_loan_account_id');
}
}

View File

@ -11,6 +11,7 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* @property \App\Enums\ChartColorScheme $chart_color_scheme
* @property bool $include_loans_in_net_worth_chart
* @property bool $include_real_estate_in_net_worth_chart
*/
class UserSetting extends Model
{
@ -21,6 +22,7 @@ class UserSetting extends Model
'user_id',
'chart_color_scheme',
'include_loans_in_net_worth_chart',
'include_real_estate_in_net_worth_chart',
];
protected function casts(): array
@ -28,6 +30,7 @@ class UserSetting extends Model
return [
'chart_color_scheme' => ChartColorScheme::class,
'include_loans_in_net_worth_chart' => 'boolean',
'include_real_estate_in_net_worth_chart' => 'boolean',
];
}

View File

@ -0,0 +1,33 @@
<?php
namespace App\Policies;
use App\Models\RealEstateDetail;
use App\Models\User;
class RealEstateDetailPolicy
{
/**
* Determine whether the user can view the model.
*/
public function view(User $user, RealEstateDetail $realEstateDetail): bool
{
return $user->id === $realEstateDetail->account->user_id;
}
/**
* Determine whether the user can update the model.
*/
public function update(User $user, RealEstateDetail $realEstateDetail): bool
{
return $user->id === $realEstateDetail->account->user_id;
}
/**
* Determine whether the user can delete the model.
*/
public function delete(User $user, RealEstateDetail $realEstateDetail): bool
{
return $user->id === $realEstateDetail->account->user_id;
}
}

View File

@ -52,5 +52,6 @@ class AppServiceProvider extends ServiceProvider
Feature::define('open-banking', fn (User $user) => false);
Feature::define('account-mapping', fn (User $user) => false);
Feature::define('real-estate', fn (User $user) => false);
}
}

View File

@ -47,4 +47,12 @@ class AccountFactory extends Factory
'linked_at' => now(),
]);
}
public function realEstate(): static
{
return $this->state(fn (array $attributes) => [
'type' => AccountType::RealEstate,
'bank_id' => null,
]);
}
}

View File

@ -0,0 +1,50 @@
<?php
namespace Database\Factories;
use App\Enums\AccountType;
use App\Enums\PropertyType;
use App\Models\Account;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\RealEstateDetail>
*/
class RealEstateDetailFactory extends Factory
{
/**
* Define the model's default state.
*
* @return array<string, mixed>
*/
public function definition(): array
{
return [
'account_id' => Account::factory()->state(['type' => AccountType::RealEstate]),
'property_type' => fake()->randomElement(PropertyType::cases()),
'address' => fake()->address(),
'purchase_price' => fake()->numberBetween(10000000, 100000000),
'purchase_date' => fake()->dateTimeBetween('-10 years', 'now'),
'area_value' => fake()->randomFloat(2, 50, 500),
'area_unit' => fake()->randomElement(['sqm', 'sqft']),
'notes' => fake()->optional()->sentence(),
];
}
/**
* State for a property with a linked loan account.
*/
public function withLinkedLoan(?Account $loanAccount = null): static
{
return $this->state(function (array $attributes) use ($loanAccount) {
$loan = $loanAccount ?? Account::factory()->state([
'type' => AccountType::Loan,
'user_id' => Account::find($attributes['account_id'])?->user_id,
]);
return [
'linked_loan_account_id' => $loan instanceof Account ? $loan->id : $loan,
];
});
}
}

View File

@ -22,6 +22,7 @@ class UserSettingFactory extends Factory
'user_id' => User::factory(),
'chart_color_scheme' => ChartColorScheme::Colorful,
'include_loans_in_net_worth_chart' => true,
'include_real_estate_in_net_worth_chart' => true,
];
}

View File

@ -0,0 +1,36 @@
<?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::create('real_estate_details', function (Blueprint $table) {
$table->char('id', 36)->primary();
$table->foreignUuid('account_id')->unique()->constrained()->onDelete('cascade');
$table->foreignUuid('linked_loan_account_id')->nullable()->constrained('accounts')->onDelete('set null');
$table->string('property_type');
$table->text('address')->nullable();
$table->bigInteger('purchase_price')->nullable();
$table->date('purchase_date')->nullable();
$table->decimal('area_value', 12, 2)->nullable();
$table->string('area_unit', 20)->nullable();
$table->text('notes')->nullable();
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('real_estate_details');
}
};

View File

@ -0,0 +1,32 @@
<?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('accounts', function (Blueprint $table) {
$table->dropForeign('accounts_bank_id_foreign');
$table->char('bank_id', 36)->nullable()->change();
$table->foreign('bank_id')->references('id')->on('banks')->cascadeOnDelete();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('accounts', function (Blueprint $table) {
$table->dropForeign(['bank_id']);
$table->char('bank_id', 36)->nullable(false)->change();
$table->foreign('bank_id')->references('id')->on('banks')->cascadeOnDelete();
});
}
};

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('user_settings', function (Blueprint $table) {
$table->boolean('include_real_estate_in_net_worth_chart')->default(true);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('user_settings', function (Blueprint $table) {
$table->dropColumn('include_real_estate_in_net_worth_chart');
});
}
};

View File

@ -125,6 +125,8 @@
"Add the app to your home screen for the best experience.": "A\u00f1ade la app a tu pantalla de inicio para la mejor experiencia.",
"Add to Home Screen": "A\u00f1adir a pantalla de inicio",
"Add transaction": "Agregar transacci\u00f3n",
"Additional notes about this property": "Notas adicionales sobre esta propiedad",
"Address": "Direcci\u00f3n",
"Address:": "Direcci\u00f3n:",
"Alex T.": "Alex T.",
"All": "Todo",
@ -166,6 +168,7 @@
"Are you sure you want to delete this owed amount record? This action cannot be undone.": "\u00bfEst\u00e1s seguro de que deseas eliminar este registro de monto adeudado? Esta acci\u00f3n no se puede deshacer.",
"Are you sure you want to delete this transaction? This action cannot be undone.": "\u00bfEst\u00e1s seguro de que quieres eliminar esta transacci\u00f3n? Esta acci\u00f3n no se puede deshacer.",
"Are you sure you want to delete your account?": "\u00bfEst\u00e1s seguro de que deseas eliminar tu cuenta?",
"Area": "Superficie",
"As a developer, I appreciate the security architecture. This is how finance apps should be built.": "Como desarrollador, aprecio la arquitectura de seguridad. As\u00ed es como deber\u00edan construirse las apps de finanzas.",
"As a solo founder, your feedback directly shapes what I build next. I personally read every response and take your suggestions seriously. When you subscribe, you're not just supporting some big corporation - you're helping me continue building something I'm passionate about.": "Como fundador en solitario, tus comentarios moldean directamente lo que construyo a continuaci\u00f3n. Leo personalmente cada respuesta y tomo tus sugerencias en serio. Cuando te suscribes, no est\u00e1s apoyando a una gran corporaci\u00f3n, est\u00e1s ayud\u00e1ndome a seguir construyendo algo que me apasiona.",
"As a user in the European Union, you have the following rights regarding your personal data:": "Como usuario en la Uni\u00f3n Europea, tienes los siguientes derechos con respecto a tus datos personales:",
@ -197,13 +200,13 @@
"Balance Date": "Fecha del Balance",
"Balance History": "Historial de Balance",
"Balance Tracking": "Seguimiento de Balance",
"Balances": "Balances",
"Balance evolution": "Evoluci\u00f3n del balance",
"Balances": "Balances",
"Bank": "Banco",
"Bank Account": "Cuenta Bancaria",
"Bank account reconnected successfully.": "Cuenta bancaria reconectada exitosamente.",
"Bank Connections": "Conexiones Bancarias",
"Bank Name": "Nombre del Banco",
"Bank account reconnected successfully.": "Cuenta bancaria reconectada exitosamente.",
"Bank accounts": "Cuentas bancarias",
"Bank connections automatically sync your transactions directly from your bank. This feature requires the Standard Plan.": "Las conexiones bancarias sincronizan autom\u00e1ticamente tus transacciones desde tu banco. Esta funci\u00f3n requiere el Plan Standard.",
"Bank logo preview": "Vista previa del logo del banco",
@ -221,13 +224,13 @@
"Blue": "Azul",
"Browse Files": "Explorar Archivos",
"Budget Name": "Nombre del Presupuesto",
"Budget Spending": "Gasto del Presupuesto",
"Budget Tracking": "Seguimiento de Presupuesto",
"Budget alert and limit adjustment": "Alerta de presupuesto y ajuste de l\u00edmite",
"Budget goals by category": "Objetivos de presupuesto por categor\u00eda",
"Budget insights and alerts": "Informes y alertas de presupuesto",
"Budget progress for Groceries": "Progreso del presupuesto de Supermercado",
"Budget resets to zero at the start of each period.": "El presupuesto se reinicia a cero al inicio de cada per\u00edodo.",
"Budget Spending": "Gasto del Presupuesto",
"Budget Tracking": "Seguimiento de Presupuesto",
"Budgets": "Presupuestos",
"Build better budgets": "Crea mejores presupuestos",
"Built by two people, for real people": "Construido por dos personas, para personas reales",
@ -239,11 +242,14 @@
"Can I export or delete my data?": "\u00bfPuedo exportar o eliminar mis datos?",
"Cancel": "Cancelar",
"Carry Over": "Acumular",
"Cash inflow": "Entrada de efectivo",
"Cash outflow": "Salida de efectivo",
"Cashflow": "Flujo de Caja",
"Cashflow Trend": "Tendencia del Flujo de Caja",
"Cashflow analytics": "Analitica de flujo de caja",
"Cashflow at a Glance": "Flujo de Caja de un Vistazo",
"Cashflow visualization": "Visualizaci\u00f3n del flujo de caja",
"Cashflow income vs expenses bar chart": "Gr\u00e1fico de barras de ingresos frente a gastos",
"Cashflow visualization": "Visualizaci\u00f3n del flujo de caja",
"Categories": "Categor\u00edas",
"Categories below 5% of total": "Categor\u00edas por debajo del 5% del total",
"Categories settings": "Configuraci\u00f3n de categor\u00edas",
@ -263,16 +269,17 @@
"Check your inbox \u2014 we've sent you an email.": "Revisa tu bandeja de entrada \u2014 te hemos enviado un correo.",
"Checking": "Cuenta Corriente",
"Choose how to handle each account from :bank. You can create new accounts, link to existing ones, or skip.": "Elige c\u00f3mo gestionar cada cuenta de :bank. Puedes crear nuevas cuentas, vincularlas a las existentes u omitirlas.",
"Choose how to report this transfer": "Elige como mostrar esta transferencia",
"Choose how you want to add your account.": "Elige c\u00f3mo quieres a\u00f1adir tu cuenta.",
"Choose the account where transactions will be imported": "Elige la cuenta donde se importar\u00e1n las transacciones",
"Choose the color palette for your charts": "Elige la paleta de colores para tus gr\u00e1ficos",
"Choose the plan that works for you": "Elige el plan que funcione para ti",
"Choose the plan that works for you. No hidden fees.": "Elige el plan que mejor se adapte a ti. Sin tarifas ocultas.",
"Choose your billing cycle": "Elige tu ciclo de facturaci\u00f3n",
"City Electric Co.": "City Electric Co.",
"Claim Your Discount": "Reclama Tu Descuento",
"Clean interface, powerful features, and zero compromise on privacy. What more could you want?": "Interfaz limpia, funciones potentes y cero compromisos con la privacidad. \u00bfQu\u00e9 m\u00e1s podr\u00edas querer?",
"Clear": "Limpiar",
"City Electric Co.": "City Electric Co.",
"Clear Encryption Key?": "\u00bfBorrar Clave de Encriptaci\u00f3n?",
"Clear Key": "Borrar Clave",
"Clear selection": "Limpiar selecci\u00f3n",
@ -286,6 +293,7 @@
"Color": "Color",
"Colorful": "Colorido",
"Columns": "Columnas",
"Commercial": "Comercial",
"Common Questions": "Preguntas Frecuentes",
"Community": "Comunidad",
"Company Name:": "Nombre de la Empresa:",
@ -302,11 +310,11 @@
"Connect Bank Account": "Conectar Cuenta Bancaria",
"Connect Your Bank": "Conecta tu banco",
"Connect Your Banks": "Conecta tus bancos",
"Connect bank accounts": "Conectar cuentas bancarias",
"Connect in seconds": "Conecta en segundos",
"Connect your Binance account using your API Key and Secret.": "Conecta tu cuenta de Binance usando tu Clave API y Secreto.",
"Connect your Bitpanda account using your API Key.": "Conecta tu cuenta de Bitpanda usando tu Clave API.",
"Connect your Indexa Capital account using your API token.": "Conecta tu cuenta de Indexa Capital usando tu token API.",
"Connect bank accounts": "Conectar cuentas bancarias",
"Connect your bank accounts": "Conecta tus cuentas bancarias",
"Connect your bank accounts, savings, investments, and more \u2014 all in a single dashboard. No more switching between apps or losing track of accounts.": "Conecta tus cuentas bancarias, ahorros, inversiones y m\u00e1s, todo en un \u00fanico panel. Sin m\u00e1s cambiar entre apps ni perder el control de tus cuentas.",
"Connect your bank and sync transactions automatically.": "Conecta tu banco y sincroniza las transacciones autom\u00e1ticamente.",
@ -396,8 +404,8 @@
"Delete account": "Eliminar cuenta",
"Delete accounts": "Eliminar cuentas",
"Delete balance": "Eliminar balance",
"Delete owed amount": "Eliminar monto adeudado",
"Delete budget": "Eliminar presupuesto",
"Delete owed amount": "Eliminar monto adeudado",
"Delete your account and all of its resources": "Elimina tu cuenta y todos sus recursos",
"Deleting...": "Eliminando...",
"Demo encryption password:": "Contrase\u00f1a de encriptaci\u00f3n de demo:",
@ -414,6 +422,7 @@
"Disconnecting...": "Desconectando...",
"Discord": "Discord",
"Discord for 80% off": "Discord con 80% de descuento",
"Do not show": "No mostrar",
"Do you connect directly to my bank?": "\u00bfOs conect\u00e1is directamente a mi banco?",
"Don't have an account?": "\u00bfNo tienes una cuenta?",
"Download as CSV or Excel format": "Descargar en formato CSV o Excel",
@ -429,13 +438,13 @@
"Edit Balance": "Editar Balance",
"Edit Budget": "Editar Presupuesto",
"Edit Category": "Editar Categor\u00eda",
"Edit Owed Amount": "Editar Monto Adeudado",
"Edit Label": "Editar Etiqueta",
"Edit Owed Amount": "Editar Monto Adeudado",
"Edit Property Details": "Editar Detalles de Propiedad",
"Edit Transaction": "Editar transacci\u00f3n",
"Edit account": "Editar cuenta",
"Edit budget": "Editar presupuesto",
"Electricity": "Electricidad",
"Emergency Fund": "Fondo de Emergencia",
"Email": "Correo Electr\u00f3nico",
"Email address": "Direcci\u00f3n de correo electr\u00f3nico",
"Email address, name, and password (encrypted)": "Direcci\u00f3n de correo electr\u00f3nico, nombre y contrase\u00f1a (encriptados)",
@ -443,6 +452,7 @@
"Email verification": "Verificaci\u00f3n de correo electr\u00f3nico",
"Email:": "Correo:",
"Email\\n address, name, and password (encrypted)": "Direcci\u00f3n de correo electr\u00f3nico, nombre y contrase\u00f1a (cifrada)",
"Emergency Fund": "Fondo de Emergencia",
"Emma L.": "Emma L.",
"Enable 2FA": "Activar 2FA",
"Enable privacy mode": "Activar modo privado",
@ -471,8 +481,8 @@
"Enter your encryption password to decrypt transactions information and accounts name.": "Ingresa tu contrase\u00f1a de encriptaci\u00f3n para descifrar la informaci\u00f3n de transacciones y nombres de cuentas.",
"Enter your encryption password to decrypt your transactions information.": "Introduce tu contrase\u00f1a de cifrado para descifrar la informaci\u00f3n de tus transacciones.",
"Enter your new API credentials for :provider.": "Introduce tus nuevas credenciales API para :provider.",
"Entertainment (Movies, Sports, Hobbies)": "Entretenimiento (Pel\u00edculas, Deportes, Hobbies)",
"Entertainment": "Entretenimiento",
"Entertainment (Movies, Sports, Hobbies)": "Entretenimiento (Pel\u00edculas, Deportes, Hobbies)",
"Entertainment \u00b7 Only $8 remaining this month": "Entretenimiento \u00b7 Solo $8 restante este mes",
"Entertainment \u2192 $100": "Entretenimiento \u2192 $100",
"Error": "Error",
@ -532,9 +542,9 @@
"Forgot password": "Olvid\u00e9 mi contrase\u00f1a",
"Forgot password?": "\u00bfOlvidaste tu contrase\u00f1a?",
"Founders of Whisper Money": "Fundadores de Whisper Money",
"Free": "Gratis",
"Freelance Invoice": "Factura Freelance",
"Frequently Asked Questions": "Preguntas Frecuentes",
"Free": "Gratis",
"From": "Desde",
"Full name": "Nombre completo",
"Fully transparent and open source. Review the code yourself.": "Totalmente transparente y de c\u00f3digo abierto. Revisa el c\u00f3digo t\u00fa mismo.",
@ -549,18 +559,18 @@
"Get started at no cost. No bank connections included.": "Empieza sin coste. Sin conexiones bancarias incluidas.",
"Get started quickly with your existing financial data.": "Comienza r\u00e1pidamente con tus datos financieros existentes.",
"Github": "Github",
"Gold Card": "Tarjeta Oro",
"Go to Dashboard": "Ir al Panel",
"Go to your account's transaction history": "Ve al historial de transacciones de tu cuenta",
"Go to your dashboard and click \"Import Transactions\". Select your CSV file and I'll map the columns automatically.": "Ve a tu panel y haz clic en \"Importar Transacciones\". Selecciona tu archivo CSV y mapear\u00e9 las columnas autom\u00e1ticamente.",
"Go to your dashboard and click \"Import Transactions\". Select your CSV file and we'll map the columns automatically.": "Ve a tu panel y haz clic en \"Importar Transacciones\". Selecciona tu archivo CSV y mapearemos las columnas autom\u00e1ticamente.",
"Gold Card": "Tarjeta Oro",
"Good": "Buena",
"Good progress on your savings.": "Buen progreso en tus ahorros.",
"Got it": "Entendido",
"Great Progress!": "\u00a1Gran Progreso!",
"Great job! You're saving well.": "\u00a1Excelente trabajo! Est\u00e1s ahorrando bien.",
"Groups joined by:": "Grupos unidos por:",
"Groceries": "Supermercado",
"Groups joined by:": "Grupos unidos por:",
"Health & Wellness (Medical, Pharmacy, Fitness)": "Salud y Bienestar (M\u00e9dico, Farmacia, Gimnasio)",
"Health and pharmaceuticals": "Salud y farmacia",
"Healthy Pharmacy": "Healthy Pharmacy",
@ -627,19 +637,20 @@
"Import a year's worth of transactions in under 10 seconds. Simply export a CSV or XLS file from your bank and drag it into Whisper Money. All data is encrypted locally before upload.": "Importa transacciones de un a\u00f1o completo en menos de 10 segundos. Simplemente exporta un archivo CSV o XLS desde tu banco y arr\u00e1stralo a Whisper Money. Todos los datos se encriptan localmente antes de subirse.",
"Import balance history from CSV files": "Importar historial de balances desde archivos CSV",
"Import balances": "Importar balances",
"Import owed amounts": "Importar montos adeudados",
"Import in Seconds": "Importa en Segundos",
"Import in seconds": "Importa en segundos",
"Import market values": "Importar valores de mercado",
"Import owed amounts": "Importar montos adeudados",
"Import transactions": "Importar transacciones",
"Import transactions for your account. You can export transaction history from your bank's website.": "Importa transacciones para tu cuenta. Puedes exportar el historial de transacciones desde la web de tu banco.",
"Import transactions from a CSV file": "Importar transacciones desde un archivo CSV",
"Import transactions from CSV or Excel files": "Importar transacciones desde archivos CSV o Excel",
"Import transactions from CSV/Excel": "Importar transacciones desde CSV/Excel",
"Import transactions from a CSV file": "Importar transacciones desde un archivo CSV",
"Import transactions in seconds": "Importa transacciones en segundos",
"Import your transaction history to start tracking your finances.": "Importa tu historial de transacciones para empezar a rastrear tus finanzas.",
"Imported": "Importado",
"Importing Transactions": "Importando Transacciones",
"Importing your balances...": "Importando tus saldos...",
"Imported": "Importado",
"Include loan balances in the net worth totals and chart": "Incluir los saldos de pr\u00e9stamos en los totales y el gr\u00e1fico del patrimonio neto",
"Include loans": "Incluir pr\u00e9stamos",
"Income": "Ingresos",
@ -676,11 +687,13 @@
"Keep me logged in": "Mantenerme conectado",
"Keep me logged in (convenient)": "Mantenerme conectado (conveniente)",
"Keep me logged in (less secure)": "Mantenerme conectado (menos seguro)",
"Keep this transfer out of cashflow analytics.": "Mantiene esta transferencia fuera de la analitica de flujo de caja.",
"Key Storage": "Almacenamiento de Clave",
"Label (Optional)": "Etiqueta (Opcional)",
"Label name": "Nombre de etiqueta",
"Labels": "Etiquetas",
"Labels settings": "Configuraci\u00f3n de etiquetas",
"Land": "Terreno",
"Language": "Idioma",
"Last Login": "\u00daltimo acceso",
"Last period:": "Per\u00edodo anterior:",
@ -699,8 +712,10 @@
"Lightning fast": "Ultrarr\u00e1pido",
"Lightning-Fast CSV/XLS Import": "Importaci\u00f3n Ultrarr\u00e1pida de CSV/XLS",
"Limit adjusted": "L\u00edmite ajustado",
"Link a loan account to track equity (market value minus owed amount).": "Vincula una cuenta de pr\u00e9stamo para rastrear el patrimonio (valor de mercado menos monto adeudado).",
"Link to existing account": "Vincular a cuenta existente",
"Link your bank accounts directly. Transactions sync automatically, giving you a real-time view of your finances.": "Vincula tus cuentas bancarias directamente. Las transacciones se sincronizan autom\u00e1ticamente, d\u00e1ndote una vista en tiempo real de tus finanzas.",
"Linked Mortgage / Loan": "Hipoteca / Pr\u00e9stamo Vinculado",
"Load more": "Cargar m\u00e1s",
"Loading": "Cargando",
"Loading last balance...": "Cargando \u00faltimo balance...",
@ -746,6 +761,8 @@
"Map Accounts": "Mapear Cuentas",
"Map Bank Accounts": "Mapear Cuentas Bancarias",
"Map Columns": "Mapear Columnas",
"Market Value": "Valor de Mercado",
"Market Values": "Valores de Mercado",
"Match your file columns to transaction fields": "Asocia las columnas de tu archivo con los campos de transacci\u00f3n",
"Max": "M\u00e1x",
"Maybe later": "Quiz\u00e1s m\u00e1s tarde",
@ -759,6 +776,7 @@
"Month": "Mes",
"Monthly": "Mensual",
"Monthly income, expenses, and net cashflow over the last 12 months": "Ingresos, gastos y flujo de efectivo neto mensual durante los \u00faltimos 12 meses",
"Monthly income, expenses, tracked transfers, and net cashflow over the last 12 months": "Ingresos, gastos, transferencias seguidas y flujo de caja neto mensual durante los ultimos 12 meses",
"More": "M\u00e1s",
"More actions": "M\u00e1s acciones",
"More options": "M\u00e1s opciones",
@ -811,6 +829,7 @@
"No income this period": "Sin ingresos en este per\u00edodo",
"No labels found.": "No se encontraron etiquetas.",
"No limits on bank accounts, transactions, or categories.": "Sin l\u00edmites en cuentas bancarias, transacciones o categor\u00edas.",
"No linked loan": "Sin pr\u00e9stamo vinculado",
"No owed amount data available": "No hay datos de monto adeudado disponibles",
"No owed amount records found.": "No se encontraron registros de monto adeudado.",
"No results found": "No se encontraron resultados",
@ -833,8 +852,6 @@
"Once verified, you'll be able to set up your encryption key and start tracking your finances with full privacy.": "Una vez verificado, podr\u00e1s configurar tu clave de encriptaci\u00f3n y empezar a rastrear tus finanzas con total privacidad.",
"Once your account is deleted, all of its resources and data will also be permanently deleted. Please enter your password to confirm you would like to permanently delete your account.": "Una vez que tu cuenta sea eliminada, todos sus recursos y datos tambi\u00e9n ser\u00e1n eliminados permanentemente. Por favor ingresa tu contrase\u00f1a para confirmar que deseas eliminar permanentemente tu cuenta.",
"Once your account is deleted, all of its resources\\n and data will also be permanently deleted. Please\\n enter your password to confirm you would like to\\n permanently delete your account.": "Una vez que tu cuenta sea eliminada, todos sus recursos y datos tambi\u00e9n ser\u00e1n eliminados permanentemente. Por favor, introduce tu contrase\u00f1a para confirmar que deseas eliminar permanentemente tu cuenta.",
"only with you": "solo contigo",
"Quartely": "Cada 4 meses",
"Only you know this password. It never leaves your device.": "Solo t\u00fa conoces esta contrase\u00f1a. Nunca sale de tu dispositivo.",
"Open menu": "Abrir men\u00fa",
"Open source": "C\u00f3digo abierto",
@ -874,10 +891,10 @@
"Percentage of income saved": "Porcentaje de ingresos ahorrados",
"Perfect for investment portfolios and retirement accounts": "Perfecto para carteras de inversi\u00f3n y cuentas de jubilaci\u00f3n",
"Perfect for investment portfolios and retirement\\n accounts": "Perfecto para carteras de inversi\u00f3n y cuentas de jubilaci\u00f3n",
"Personal transfers": "Transferencias personales",
"Period": "Per\u00edodo",
"Period Duration (days)": "Duraci\u00f3n del Per\u00edodo (d\u00edas)",
"Period Type": "Tipo de Per\u00edodo",
"Personal transfers": "Transferencias personales",
"Pink": "Rosa",
"Platform": "Plataforma",
"Please enter a balance": "Por favor, ingresa un balance",
@ -920,11 +937,19 @@
"Pro Yearly": "Pro Anual",
"Profile Settings": "Configuraci\u00f3n del Perfil",
"Profile information": "Informaci\u00f3n del perfil",
"Property Details": "Detalles de Propiedad",
"Property Type": "Tipo de Propiedad",
"Property address": "Direcci\u00f3n de la propiedad",
"Protect your privacy with no tracking or third-party analytics.": "Protege tu privacidad sin rastreo ni an\u00e1lisis de terceros.",
"Update categories automatically": "Actualizar categor\u00edas autom\u00e1ticamente",
"Purchase Date": "Fecha de Compra",
"Purchase Price": "Precio de Compra",
"Quartely": "Cada 4 meses",
"Re-evaluate rules": "Reevaluar reglas",
"Reactivate Your Subscription": "Reactiva Tu Suscripci\u00f3n",
"Ready to take control of your finances?": "\u00bfListo para tomar el control de tus finanzas?",
"Real Estate": "Bienes Ra\u00edces",
"Include real estate": "Incluir bienes ra\u00edces",
"Include real estate assets in the net worth totals and chart": "Incluir bienes ra\u00edces en los totales y gr\u00e1fico de patrimonio neto",
"Reconnect": "Reconectar",
"Recovery Codes": "C\u00f3digos de Recuperaci\u00f3n",
"Recovery codes": "C\u00f3digos de recuperaci\u00f3n",
@ -939,11 +964,12 @@
"Remaining balance returns to available money pool": "El saldo restante vuelve al fondo de dinero disponible",
"Remember me": "Recu\u00e9rdame",
"Remove all labels": "Eliminar todas las etiquetas",
"Rent Payment": "Pago de Alquiler",
"Rent and maintanence": "Alquiler y mantenimiento",
"Resend verification email": "Reenviar correo de verificaci\u00f3n",
"Reset password": "Restablecer contrase\u00f1a",
"Reset/Pool": "Reiniciar/Fondo",
"Rent Payment": "Pago de Alquiler",
"Rent and maintanence": "Alquiler y mantenimiento",
"Residential": "Residencial",
"Retirement": "Jubilaci\u00f3n",
"Retirement / Pension": "Jubilaci\u00f3n / Pensi\u00f3n",
"Retry": "Reintentar",
@ -971,18 +997,18 @@
"Sarah M.": "Sarah M.",
"Save": "Guardar",
"Save & Sync": "Guardar y Sincronizar",
"Save :percent%": "Ahorra :percent%",
"Save Balance": "Guardar Balance",
"Save Changes": "Guardar Cambios",
"Save money for goals": "Ahorra dinero para tus metas",
"Save :percent%": "Ahorra :percent%",
"Save password": "Guardar contrase\u00f1a",
"Saved": "Guardado",
"Saving": "Ahorro",
"Saving...": "Guardando...",
"Savings": "Ahorros",
"Savings Rate": "Tasa de Ahorro",
"Savings rate": "Tasa de ahorro",
"Savings Transfer": "Transferencia a Ahorros",
"Savings rate": "Tasa de ahorro",
"Search": "Buscar",
"Search bank...": "Buscar banco...",
"Search banks...": "Buscar bancos...",
@ -994,11 +1020,12 @@
"Search, filter, and categorize with ease. Understand exactly where your money goes.": "Busca, filtra y categoriza con facilidad. Entiende exactamente a d\u00f3nde va tu dinero.",
"Search, move": "Buscar, mover",
"Searching...": "Buscando...",
"Secure Storage:": "Almacenamiento Seguro:",
"Secure & encrypted": "Seguro y cifrado",
"Secure Storage:": "Almacenamiento Seguro:",
"Secure upload": "Carga segura",
"See balances": "Ver balances",
"See every account in one place. Track balances, monitor changes, and always know where you stand.": "Ve todas tus cuentas en un solo lugar. Sigue los saldos, monitoriza los cambios y sabe siempre c\u00f3mo est\u00e1s.",
"See market values": "Ver valores de mercado",
"See owed amounts": "Ver montos adeudados",
"See where you spend": "Ve en qu\u00e9 gastas",
"See where you stand in real-time. Visual progress bars show spending vs. budget.": "Ve tu situaci\u00f3n en tiempo real. Las barras de progreso visuales muestran el gasto frente al presupuesto.",
@ -1020,7 +1047,6 @@
"Select balance column": "Seleccionar columna de balance",
"Select balance column (optional)": "Seleccionar columna de balance (opcional)",
"Select bank...": "Seleccionar banco...",
"Select owed amount column": "Seleccionar columna de monto adeudado",
"Select categories...": "Seleccionar categor\u00edas...",
"Select country": "Seleccionar pa\u00eds",
"Select currency": "Seleccionar moneda",
@ -1030,8 +1056,11 @@
"Select labels (optional)": "Seleccionar etiquetas (opcional)",
"Select labels...": "Seleccionar etiquetas...",
"Select language": "Seleccionar idioma",
"Select owed amount column": "Seleccionar columna de monto adeudado",
"Select property type": "Seleccionar tipo de propiedad",
"Select row": "Seleccionar fila",
"Select the country where your bank is located.": "Selecciona el pa\u00eds donde est\u00e1 ubicado tu banco.",
"Select unit": "Seleccionar unidad",
"Select your bank.": "Selecciona tu banco.",
"Select your country and bank to get started.": "Selecciona tu pa\u00eds y banco para empezar.",
"Selected": "Seleccionado",
@ -1065,6 +1094,8 @@
"Shared": "Compartido",
"Shared with": "Compartido con",
"Shopping (Clothing, Electronics, Gifts)": "Compras (Ropa, Electr\u00f3nica, Regalos)",
"Show as cash inflow": "Mostrar como entrada de efectivo",
"Show as cash outflow": "Mostrar como salida de efectivo",
"Sign up": "Crear cuenta",
"Simple, transparent pricing": "Precios simples y transparentes",
"Skip": "Saltar",
@ -1087,6 +1118,7 @@
"Spent:": "Gastado:",
"Spot savings opportunities": "Identifica oportunidades de ahorro",
"Square images only (max": "Solo im\u00e1genes cuadradas (m\u00e1x",
"Standard": "Estandar",
"Standard Plan required": "Plan Standard requerido",
"Start Day": "D\u00eda de inicio",
"Start Day of Month": "D\u00eda de inicio del mes",
@ -1153,8 +1185,8 @@
"The most secure personal finance app. Your data is never shared with third parties. Track expenses, create budgets, and manage your money privately.": "La app m\u00e1s segura de finanzas personales. Tus datos nunca se comparten con terceros. Rastrea gastos, crea presupuestos y administra tu dinero de forma privada.",
"The most secure privacy-first personal finance app. Track expenses, create budgets, and manage your money privately.": "La aplicaci\u00f3n de finanzas personales m\u00e1s segura y centrada en la privacidad. Realiza un seguimiento de gastos, crea presupuestos y gestiona tu dinero de forma privada.",
"The most secure way to understand your finances": "La forma m\u00e1s segura de entender tus finanzas",
"There are different account types. Some track transactions, others just track balance over time.": "Hay diferentes tipos de cuenta. Algunas rastrean transacciones, otras solo rastrean el balance a lo largo del tiempo.",
"Theatre, music, cinema": "Teatro, m\u00fasica, cine",
"There are different account types. Some track transactions, others just track balance over time.": "Hay diferentes tipos de cuenta. Algunas rastrean transacciones, otras solo rastrean el balance a lo largo del tiempo.",
"These Terms of Service govern your use of the Whisper Money personal finance platform.": "Estos T\u00e9rminos de Servicio rigen tu uso de la plataforma de finanzas personales Whisper Money.",
"These Terms of Service govern your use of the\\n Whisper Money personal finance platform.": "Estos T\u00e9rminos de Servicio rigen tu uso de la plataforma de finanzas personales Whisper Money.",
"These Terms, together with our Privacy Policy, constitute the entire agreement between you and Whisper Money regarding the use of our service and supersede any prior agreements or understandings, whether written or oral.": "Estos T\u00e9rminos, junto con nuestra Pol\u00edtica de Privacidad, constituyen el acuerdo completo entre t\u00fa y Whisper Money con respecto al uso de nuestro servicio y reemplazan cualquier acuerdo o entendimiento previo, ya sea escrito u oral.",
@ -1213,9 +1245,16 @@
"Track all your finances in one place \\u2014 checking,\\n savings, credit cards, investments, and more.": "Realiza un seguimiento de todas tus finanzas en un solo lugar: cuentas corrientes, ahorros, tarjetas de cr\u00e9dito, inversiones y m\u00e1s.",
"Track all your finances in one place \u2014 checking, savings, credit cards, investments, and more.": "Rastrea todas tus finanzas en un solo lugar: cuenta corriente, ahorros, tarjetas de cr\u00e9dito, inversiones y m\u00e1s.",
"Track credit card spending": "Rastrea los gastos de tarjeta de cr\u00e9dito",
"Track money entering your available cash without counting it as income.": "Sigue el dinero que entra en tu efectivo disponible sin contarlo como ingreso.",
"Track money leaving your available cash, like investments or savings transfers.": "Sigue el dinero que sale de tu efectivo disponible, como inversiones o transferencias a ahorros.",
"Track your budget progress": "Sigue el progreso de tu presupuesto",
"Track your income, expenses, and savings": "Rastrea tus ingresos, gastos y ahorros",
"Track your property market value over time. Transactions are not supported.": "Rastrea el valor de mercado de tu propiedad a lo largo del tiempo. Las transacciones no est\u00e1n soportadas.",
"Track your spending with flexible budgets": "Rastrea tus gastos con presupuestos flexibles",
"Tracked Inflows": "Entradas seguidas",
"Tracked Outflows": "Salidas seguidas",
"Tracked transfers appear in the money flow Sankey chart. They are not counted as income or expenses.": "Las transferencias seguidas aparecen en el diagrama Sankey de flujo de dinero. No se cuentan como ingresos ni gastos.",
"Tracked transfers appear only in the monthly cashflow trend. They are not counted as income, expenses, or shown in the money flow Sankey.": "Las transferencias seguidas solo aparecen en la tendencia mensual del flujo de caja. No cuentan como ingresos ni gastos, ni se muestran en el Sankey de flujo de dinero.",
"Tracking": "Seguimiento",
"Tracking spending for": "Rastreando gastos para",
"Tracking:": "Seguimiento:",
@ -1232,6 +1271,8 @@
"Transactions in this category will not be counted in top expenses or income. Transfer categories are mainly used for transactions between accounts.": "Estas transacciones no se contar\u00e1 como gasto ni como ingreso.",
"Transactions in this category will\\n not be counted in top expenses or\\n income. Transfer categories are\\n mainly used for transactions between\\n accounts.": "Las transacciones en esta categor\u00eda no se contar\u00e1n entre los principales gastos o ingresos. Las categor\u00edas de transferencia se usan principalmente para transacciones entre cuentas.",
"Transfer": "Transferencia",
"Transfer categories stay out of income and expense totals, but you can still track them in the money flow chart.": "Las categor\u00edas de transferencia quedan fuera de los totales de ingresos y gastos, pero a\u00fan puedes seguirlas en el gr\u00e1fico de flujo de dinero.",
"Transfer categories stay out of income and expense totals, but you can still track them in the monthly chart.": "Las categorias de transferencia quedan fuera de los totales de ingresos y gastos, pero aun puedes seguirlas en el grafico mensual.",
"Transfers (Between accounts, Savings)": "Transferencias (Entre cuentas, Ahorros)",
"Transportation": "Transporte",
"Transportation (Fuel, Public Transit, Parking)": "Transporte (Combustible, Transporte p\u00fablico, Aparcamiento)",
@ -1251,6 +1292,7 @@
"Understand your finances and make better decisions without the friction. Track expenses, create budgets, and achieve your goals\u2014all in one place.": "Entiende tus finanzas para tomar mejores decisiones sin fricci\u00f3n. Rastrea gastos, crea presupuestos y alcanza tus metas, todo en un solo sitio.",
"Understand your spending with beautiful charts and reports.": "Entiende tus gastos con gr\u00e1ficos y reportes hermosos.",
"Understanding Categories": "Entendiendo las Categor\u00edas",
"Unit": "Unidad",
"Unlimited Everything": "Sin L\u00edmites en Todo",
"Unlimited accounts": "Cuentas ilimitadas",
"Unlimited transaction imports": "Importaciones de transacciones ilimitadas",
@ -1267,6 +1309,8 @@
"Update account balance": "Actualizar balance de cuenta",
"Update balance": "Actualizar balance",
"Update balances periodically to track growth": "Actualiza los balances peri\u00f3dicamente para rastrear el crecimiento",
"Update categories automatically": "Actualizar categor\u00edas autom\u00e1ticamente",
"Update market value": "Actualizar valor de mercado",
"Update owed amount": "Actualizar monto adeudado",
"Update password": "Actualizar contrase\u00f1a",
"Update the account information.": "Actualiza la informaci\u00f3n de la cuenta.",
@ -1297,6 +1341,7 @@
"Use the service only for lawful purposes and in compliance with all applicable laws": "Usa el servicio solo para fines legales y en cumplimiento de todas las leyes aplicables",
"Use the service only for lawful purposes and\\n in compliance with all applicable laws": "Usa el servicio solo para fines legales y de conformidad con todas las leyes aplicables",
"User account": "Cuenta de usuario",
"Vacation": "Vacacional",
"Value": "Valor",
"Verify Email Address": "Verificar Correo Electr\u00f3nico",
"Verify Your Email - Whisper Money": "Verifica Tu Email - Whisper Money",
@ -1504,8 +1549,8 @@
"Your financial data is encrypted on your device before being transmitted to our servers, ensuring that only you can access your information": "Tus datos financieros se encriptan en tu dispositivo antes de ser transmitidos a nuestros servidores, asegurando que solo t\u00fa puedas acceder a tu informaci\u00f3n",
"Your financial data is encrypted on your device before it ever reaches our servers.": "Tus datos financieros se encriptan en tu dispositivo antes de que lleguen a nuestros servidores.",
"Your financial data is encrypted on your device. Only you can access it.": "Tus datos financieros se encriptan en tu dispositivo. Solo t\u00fa puedes acceder a ellos.",
"Your financial data stays private. The most secure way to manage your personal finances.": "Tus datos financieros permanecen privados. La forma m\u00e1s segura de gestionar tus finanzas personales.",
"Your financial data stays private and is never shared": "Tus datos financieros son privados y nunca se comparten",
"Your financial data stays private. The most secure way to manage your personal finances.": "Tus datos financieros permanecen privados. La forma m\u00e1s segura de gestionar tus finanzas personales.",
"Your financial data stays private\u2014never shared with third parties. The most secure way to manage your personal finances.": "Tus datos financieros permanecen privados\u2014nunca se comparten con terceros. La forma m\u00e1s segura de gestionar tus finanzas personales.",
"Your financial data stays private\u2014never shared with third parties. Track expenses, create budgets, and achieve your goals\u2014all while keeping your information completely secure.": "Tus datos financieros permanecen privados\u2014nunca se comparten con terceros. Rastrea gastos, crea presupuestos y alcanza tus metas, todo mientras mantienes tu informaci\u00f3n completamente segura.",
"Your first account must be a": "Tu primera cuenta debe ser una",
@ -1527,6 +1572,7 @@
"account": "cuenta",
"account.": "cuenta.",
"accounts": "cuentas",
"acres": "acres",
"after 3 months with Whisper Money": "despu\u00e9s de 3 meses con Whisper Money",
"amber": "\u00e1mbar",
"balance": "balance",
@ -1550,10 +1596,12 @@
"for the last 12 months": "en los \u00faltimos 12 meses",
"for the last 30 days": "en los \u00faltimos 30 d\u00edas",
"forever": "siempre",
"ft\u00b2": "ft\u00b2",
"fuchsia": "fucsia",
"gray": "gris",
"greater than": "mayor que",
"green": "verde",
"ha": "ha",
"index, follow": "index, follow",
"indigo": "\u00edndigo",
"is empty": "est\u00e1 vac\u00edo",
@ -1562,10 +1610,14 @@
"less than": "menor que",
"lime": "lima",
"log in": "iniciar sesi\u00f3n",
"market value": "valor de mercado",
"market values": "valores de mercado",
"m\u00b2": "m\u00b2",
"neutral": "neutro",
"of": "de",
"on the last 30 days": "en los \u00faltimos 30 d\u00edas",
"one-time": "pago \u00fanico",
"only with you": "solo contigo",
"or you can": "o puedes",
"or, enter the code manually": "o, ingresa el c\u00f3digo manualmente",
"orange": "naranja",
@ -1605,23 +1657,5 @@
"yellow": "amarillo",
"\u2014 $9/month": "\u2014 $9/mes",
"\u2190 Back to home": "\u2190 Volver al inicio",
"\ud83c\udf89 Get a founder discount \u2022": "\ud83c\udf89 Obt\u00e9n un descuento de fundador \u2022",
"Cash inflow": "Entrada de efectivo",
"Cash outflow": "Salida de efectivo",
"Cashflow analytics": "Analitica de flujo de caja",
"Choose how to report this transfer": "Elige como mostrar esta transferencia",
"Do not show": "No mostrar",
"Keep this transfer out of cashflow analytics.": "Mantiene esta transferencia fuera de la analitica de flujo de caja.",
"Monthly income, expenses, tracked transfers, and net cashflow over the last 12 months": "Ingresos, gastos, transferencias seguidas y flujo de caja neto mensual durante los ultimos 12 meses",
"Show as cash inflow": "Mostrar como entrada de efectivo",
"Show as cash outflow": "Mostrar como salida de efectivo",
"Standard": "Estandar",
"Track money entering your available cash without counting it as income.": "Sigue el dinero que entra en tu efectivo disponible sin contarlo como ingreso.",
"Track money leaving your available cash, like investments or savings transfers.": "Sigue el dinero que sale de tu efectivo disponible, como inversiones o transferencias a ahorros.",
"Tracked Inflows": "Entradas seguidas",
"Tracked Outflows": "Salidas seguidas",
"Tracked transfers appear only in the monthly cashflow trend. They are not counted as income, expenses, or shown in the money flow Sankey.": "Las transferencias seguidas solo aparecen en la tendencia mensual del flujo de caja. No cuentan como ingresos ni gastos, ni se muestran en el Sankey de flujo de dinero.",
"Tracked transfers appear in the money flow Sankey chart. They are not counted as income or expenses.": "Las transferencias seguidas aparecen en el diagrama Sankey de flujo de dinero. No se cuentan como ingresos ni gastos.",
"Transfer categories stay out of income and expense totals, but you can still track them in the monthly chart.": "Las categorias de transferencia quedan fuera de los totales de ingresos y gastos, pero aun puedes seguirlas en el grafico mensual.",
"Transfer categories stay out of income and expense totals, but you can still track them in the money flow chart.": "Las categorías de transferencia quedan fuera de los totales de ingresos y gastos, pero aún puedes seguirlas en el gráfico de flujo de dinero."
"\ud83c\udf89 Get a founder discount \u2022": "\ud83c\udf89 Obt\u00e9n un descuento de fundador \u2022"
}

View File

@ -8,14 +8,22 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Textarea } from '@/components/ui/textarea';
import {
ACCOUNT_TYPES,
AREA_UNITS,
Account,
CURRENCY_OPTIONS,
PROPERTY_TYPES,
balanceTermCapitalized,
formatAccountType,
formatAreaUnit,
formatPropertyType,
type AccountType,
type AreaUnit,
type Bank,
type CurrencyCode,
type PropertyType,
} from '@/types/account';
import { __ } from '@/utils/i18n';
import { useCallback, useEffect, useState } from 'react';
@ -29,6 +37,17 @@ const BALANCE_ACCOUNT_TYPES: AccountType[] = [
'savings',
];
export interface RealEstateFormData {
propertyType: PropertyType | null;
address: string;
purchasePrice: number;
purchaseDate: string;
areaValue: string;
areaUnit: AreaUnit | null;
linkedLoanAccountId: string | null;
notes: string;
}
export interface AccountFormData {
displayName: string;
bankId: number | null;
@ -36,16 +55,19 @@ export interface AccountFormData {
currencyCode: CurrencyCode | null;
customBank: CustomBankData | null;
balance: number | null;
realEstate: RealEstateFormData | null;
}
interface AccountFormProps {
initialValues?: {
displayName: string;
bank: Bank;
bank: Bank | null;
type: AccountType;
currencyCode: CurrencyCode;
};
forceAccountType?: AccountType;
hiddenAccountTypes?: AccountType[];
availableLoanAccounts?: Account[];
onChange: (data: AccountFormData) => void;
}
@ -55,16 +77,29 @@ const initialCustomBankData: CustomBankData = {
logoPreview: null,
};
const initialRealEstateData: RealEstateFormData = {
propertyType: null,
address: '',
purchasePrice: 0,
purchaseDate: '',
areaValue: '',
areaUnit: null,
linkedLoanAccountId: null,
notes: '',
};
export function AccountForm({
initialValues,
forceAccountType,
hiddenAccountTypes = [],
availableLoanAccounts = [],
onChange,
}: AccountFormProps) {
const [displayName, setDisplayName] = useState(
initialValues?.displayName ?? '',
);
const [selectedBankId, setSelectedBankId] = useState<number | null>(
initialValues?.bank.id ?? null,
initialValues?.bank?.id ?? null,
);
const [selectedType, setSelectedType] = useState<AccountType | null>(
initialValues?.type ?? forceAccountType ?? null,
@ -76,9 +111,13 @@ export function AccountForm({
initialCustomBankData,
);
const [balance, setBalance] = useState<number | null>(null);
const [realEstateData, setRealEstateData] = useState<RealEstateFormData>(
initialRealEstateData,
);
const showBalanceField =
selectedType !== null && BALANCE_ACCOUNT_TYPES.includes(selectedType);
const isRealEstate = selectedType === 'real_estate';
useEffect(() => {
onChange({
@ -88,6 +127,7 @@ export function AccountForm({
currencyCode: selectedCurrency,
customBank: isCreatingCustomBank ? customBankData : null,
balance: showBalanceField ? balance : null,
realEstate: isRealEstate ? realEstateData : null,
});
}, [
displayName,
@ -98,17 +138,20 @@ export function AccountForm({
customBankData,
balance,
showBalanceField,
isRealEstate,
realEstateData,
onChange,
]);
useEffect(() => {
if (initialValues) {
setDisplayName(initialValues.displayName);
setSelectedBankId(initialValues.bank.id);
setSelectedBankId(initialValues.bank?.id ?? null);
setSelectedType(initialValues.type);
setSelectedCurrency(initialValues.currencyCode);
setIsCreatingCustomBank(false);
setCustomBankData(initialCustomBankData);
setRealEstateData(initialRealEstateData);
}
}, [initialValues]);
@ -142,36 +185,6 @@ export function AccountForm({
/>
</div>
<div className="space-y-2">
<Label htmlFor="bank_id">{__('Bank')}</Label>
<div className="mt-1">
{isCreatingCustomBank ? (
<CustomBankForm
defaultName={customBankData.name}
value={customBankData}
onChange={setCustomBankData}
onCancel={handleCancelCustomBank}
/>
) : (
<>
<input
type="hidden"
name="bank_id"
value={selectedBankId ?? ''}
required
/>
<BankCombobox
value={selectedBankId}
onValueChange={setSelectedBankId}
defaultBank={initialValues?.bank}
onCreateCustomBank={handleCreateCustomBank}
/>
</>
)}
</div>
</div>
<div className="space-y-2">
<Label htmlFor="type">{__('Account Type')}</Label>
<div className="mt-1">
@ -179,9 +192,15 @@ export function AccountForm({
name="type"
value={selectedType ?? undefined}
disabled={!!forceAccountType}
onValueChange={(value) =>
setSelectedType(value as AccountType)
}
onValueChange={(value) => {
const newType = value as AccountType;
setSelectedType(newType);
if (newType === 'real_estate') {
setSelectedBankId(null);
setIsCreatingCustomBank(false);
setCustomBankData(initialCustomBankData);
}
}}
required
>
<SelectTrigger name="type">
@ -190,7 +209,9 @@ export function AccountForm({
/>
</SelectTrigger>
<SelectContent>
{ACCOUNT_TYPES.map((type) => (
{ACCOUNT_TYPES.filter(
(type) => !hiddenAccountTypes.includes(type),
).map((type) => (
<SelectItem key={type} value={type}>
{formatAccountType(type)}
</SelectItem>
@ -206,8 +227,49 @@ export function AccountForm({
)}
</p>
)}
{isRealEstate && (
<p className="pl-1 text-xs text-muted-foreground">
{__(
'Track your property market value over time. Transactions are not supported.',
)}
</p>
)}
</div>
{!isRealEstate && (
<div className="space-y-2">
<Label htmlFor="bank_id">{__('Bank')}</Label>
<div className="mt-1">
{isCreatingCustomBank ? (
<CustomBankForm
defaultName={customBankData.name}
value={customBankData}
onChange={setCustomBankData}
onCancel={handleCancelCustomBank}
/>
) : (
<>
<input
type="hidden"
name="bank_id"
value={selectedBankId ?? ''}
required
/>
<BankCombobox
value={selectedBankId}
onValueChange={setSelectedBankId}
defaultBank={
initialValues?.bank ?? undefined
}
onCreateCustomBank={handleCreateCustomBank}
/>
</>
)}
</div>
</div>
)}
<div className="space-y-2">
<Label htmlFor="currency_code">{__('Currency')}</Label>
<div className="mt-1">
@ -253,6 +315,215 @@ export function AccountForm({
</p>
</div>
)}
{isRealEstate && (
<>
<div className="space-y-2">
<Label htmlFor="property_type">
{__('Property Type')}
</Label>
<div className="mt-1">
<Select
name="property_type"
value={realEstateData.propertyType ?? undefined}
onValueChange={(value) =>
setRealEstateData((prev) => ({
...prev,
propertyType: value as PropertyType,
}))
}
required
>
<SelectTrigger name="property_type">
<SelectValue
placeholder={__('Select property type')}
/>
</SelectTrigger>
<SelectContent>
{PROPERTY_TYPES.map((type) => (
<SelectItem key={type} value={type}>
{formatPropertyType(type)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="address">{__('Address')}</Label>
<Input
id="address"
className="mt-1"
name="address"
value={realEstateData.address}
onChange={(e) =>
setRealEstateData((prev) => ({
...prev,
address: e.target.value,
}))
}
placeholder={__('Property address')}
/>
</div>
<div className="space-y-2">
<Label htmlFor="purchase_price">
{__('Purchase Price')}
</Label>
<AmountInput
id="purchase_price"
className="mt-1"
value={realEstateData.purchasePrice}
onChange={(value) =>
setRealEstateData((prev) => ({
...prev,
purchasePrice: value,
}))
}
currencyCode={selectedCurrency ?? 'USD'}
/>
</div>
<div className="space-y-2">
<Label htmlFor="purchase_date">
{__('Purchase Date')}
</Label>
<Input
id="purchase_date"
type="date"
className="mt-1"
value={realEstateData.purchaseDate}
onChange={(e) =>
setRealEstateData((prev) => ({
...prev,
purchaseDate: e.target.value,
}))
}
/>
</div>
<div className="grid grid-cols-2 gap-2">
<div className="space-y-2">
<Label htmlFor="area_value">{__('Area')}</Label>
<Input
id="area_value"
type="number"
className="mt-1"
value={realEstateData.areaValue}
onChange={(e) =>
setRealEstateData((prev) => ({
...prev,
areaValue: e.target.value,
}))
}
placeholder="0"
min="0"
step="0.01"
/>
</div>
<div className="space-y-2">
<Label htmlFor="area_unit">{__('Unit')}</Label>
<div className="mt-1">
<Select
name="area_unit"
value={realEstateData.areaUnit ?? undefined}
onValueChange={(value) =>
setRealEstateData((prev) => ({
...prev,
areaUnit: value as AreaUnit,
}))
}
>
<SelectTrigger name="area_unit">
<SelectValue
placeholder={__('Select unit')}
/>
</SelectTrigger>
<SelectContent>
{AREA_UNITS.map((unit) => (
<SelectItem key={unit} value={unit}>
{formatAreaUnit(unit)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</div>
{availableLoanAccounts.length > 0 && (
<div className="space-y-2">
<Label htmlFor="linked_loan_account_id">
{__('Linked Mortgage / Loan')}
</Label>
<p className="text-xs text-muted-foreground">
{__(
'Link a loan account to track equity (market value minus owed amount).',
)}
</p>
<div className="mt-1">
<Select
name="linked_loan_account_id"
value={
realEstateData.linkedLoanAccountId ??
'none'
}
onValueChange={(value) =>
setRealEstateData((prev) => ({
...prev,
linkedLoanAccountId:
value === 'none' ? null : value,
}))
}
>
<SelectTrigger name="linked_loan_account_id">
<SelectValue
placeholder={__('No linked loan')}
/>
</SelectTrigger>
<SelectContent>
<SelectItem value="none">
{__('No linked loan')}
</SelectItem>
{availableLoanAccounts.map((loan) => (
<SelectItem
key={loan.id}
value={loan.id}
>
{loan.name}{' '}
{loan.bank
? `(${loan.bank.name})`
: ''}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
)}
<div className="space-y-2">
<Label htmlFor="notes">{__('Notes')}</Label>
<Textarea
id="notes"
className="mt-1"
name="notes"
value={realEstateData.notes}
onChange={(e) =>
setRealEstateData((prev) => ({
...prev,
notes: e.target.value,
}))
}
placeholder={__(
'Additional notes about this property',
)}
rows={3}
/>
</div>
</>
)}
</>
);
}

View File

@ -6,7 +6,7 @@ import { AmountDisplay } from '@/components/ui/amount-display';
import { Card, CardContent } from '@/components/ui/card';
import { useChartColors } from '@/hooks/use-chart-color-scheme';
import { AccountWithMetrics } from '@/hooks/use-dashboard-data';
import { supportsInvestedAmount } from '@/types/account';
import { formatAccountType, supportsInvestedAmount } from '@/types/account';
import { __ } from '@/utils/i18n';
import { Link } from '@inertiajs/react';
import { useState } from 'react';
@ -68,12 +68,14 @@ export function AccountListCard({
className="-my-1 -ml-1.5 flex min-w-0 items-center rounded-md px-1.5 py-1 transition-colors hover:bg-muted"
>
<h3 className="flex min-w-0 items-center gap-2 font-semibold">
<BankLogo
src={account.bank?.logo}
name={account.bank?.name}
className="size-4 shrink-0"
fallback="letter"
/>
{account.bank && (
<BankLogo
src={account.bank.logo}
name={account.bank.name}
className="size-4 shrink-0"
fallback="letter"
/>
)}
<AccountName
account={account}
length={{ min: 8, max: 25 }}
@ -83,7 +85,8 @@ export function AccountListCard({
</Link>
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<span>
{account.bank?.name || 'Unknown Bank'}
{account.bank?.name ||
formatAccountType(account.type)}
</span>
</div>
</div>
@ -254,7 +257,9 @@ export function AccountListCard({
>
{account.type === 'loan'
? __('Update owed amount')
: __('Update balance')}
: account.type === 'real_estate'
? __('Update market value')
: __('Update balance')}
</Button>
)}

View File

@ -13,10 +13,11 @@ import {
DialogTrigger,
} from '@/components/ui/dialog';
import { SharedData } from '@/types';
import { Account } from '@/types/account';
import { __ } from '@/utils/i18n';
import { router, usePage } from '@inertiajs/react';
import { Link2, PenLine } from 'lucide-react';
import React, { useCallback, useRef, useState } from 'react';
import React, { useCallback, useMemo, useRef, useState } from 'react';
import { AccountForm, AccountFormData } from './account-form';
type Mode = 'choice' | 'manual';
@ -28,10 +29,22 @@ export function CreateAccountDialog({
onSuccess?: () => void;
trigger?: React.ReactNode;
}) {
const { features, auth, subscriptionsEnabled } =
usePage<SharedData>().props;
const {
features,
auth,
subscriptionsEnabled,
accounts: sharedAccounts,
} = usePage<SharedData>().props;
const openBankingEnabled = features['open-banking'];
const realEstateEnabled = features['real-estate'];
const isFreePlan = subscriptionsEnabled && !auth?.hasProPlan;
const availableLoanAccounts = useMemo(
() =>
((sharedAccounts as Account[]) || []).filter(
(a) => a.type === 'loan',
),
[sharedAccounts],
);
const [open, setOpen] = useState(false);
const [mode, setMode] = useState<Mode>(
@ -47,6 +60,7 @@ export function CreateAccountDialog({
currencyCode: null,
customBank: null,
balance: null,
realEstate: null,
});
const handleFormChange = useCallback((data: AccountFormData) => {
@ -109,41 +123,70 @@ export function CreateAccountDialog({
return;
}
const isRealEstate = type === 'real_estate';
setIsSubmitting(true);
try {
let finalBankId: string;
let finalBankId: string | null = null;
if (customBank) {
if (!customBank.name.trim()) {
alert('Please enter a bank name.');
setIsSubmitting(false);
return;
if (!isRealEstate) {
if (customBank) {
if (!customBank.name.trim()) {
alert('Please enter a bank name.');
setIsSubmitting(false);
return;
}
const createdBankId = await createBankAndGetId();
if (!createdBankId) {
throw new Error('Failed to create bank');
}
finalBankId = createdBankId;
} else {
if (!bankId) {
alert('Please select a bank.');
setIsSubmitting(false);
return;
}
finalBankId = String(bankId);
}
const createdBankId = await createBankAndGetId();
if (!createdBankId) {
throw new Error('Failed to create bank');
}
finalBankId = createdBankId;
} else {
if (!bankId) {
alert('Please select a bank.');
setIsSubmitting(false);
return;
}
finalBankId = String(bankId);
}
router.post(
store.url(),
{
name: displayName,
bank_id: finalBankId,
...(finalBankId ? { bank_id: finalBankId } : {}),
type: type,
currency_code: currencyCode,
...(formDataRef.current.balance
? { balance: formDataRef.current.balance }
: {}),
...(formDataRef.current.realEstate
? {
property_type:
formDataRef.current.realEstate.propertyType,
address:
formDataRef.current.realEstate.address ||
null,
purchase_price:
formDataRef.current.realEstate
.purchasePrice || null,
purchase_date:
formDataRef.current.realEstate.purchaseDate ||
null,
area_value:
formDataRef.current.realEstate.areaValue ||
null,
area_unit:
formDataRef.current.realEstate.areaUnit,
linked_loan_account_id:
formDataRef.current.realEstate
.linkedLoanAccountId,
notes:
formDataRef.current.realEstate.notes || null,
}
: {}),
},
{
onSuccess: () => {
@ -234,7 +277,13 @@ export function CreateAccountDialog({
{mode === 'manual' && (
<form onSubmit={handleSubmit} className="space-y-2">
<AccountForm onChange={handleFormChange} />
<AccountForm
onChange={handleFormChange}
availableLoanAccounts={availableLoanAccounts}
hiddenAccountTypes={
realEstateEnabled ? [] : ['real_estate']
}
/>
<div className="flex justify-end gap-2 pt-4">
<Button

View File

@ -35,10 +35,11 @@ export function EditAccountDialog({
const [isSubmitting, setIsSubmitting] = useState(false);
const formDataRef = useRef<AccountFormData>({
displayName: '',
bankId: account.bank.id,
bankId: account.bank?.id ?? null,
type: account.type,
currencyCode: account.currency_code,
customBank: null,
realEstate: null,
});
useEffect(() => {
@ -135,36 +136,40 @@ export function EditAccountDialog({
return;
}
const isRealEstate = type === 'real_estate';
setIsSubmitting(true);
try {
let finalBankId: string;
let finalBankId: string | null = null;
if (customBank) {
if (!customBank.name.trim()) {
alert('Please enter a bank name.');
setIsSubmitting(false);
return;
if (!isRealEstate) {
if (customBank) {
if (!customBank.name.trim()) {
alert('Please enter a bank name.');
setIsSubmitting(false);
return;
}
const createdBankId = await createBankAndGetId();
if (!createdBankId) {
throw new Error('Failed to create bank');
}
finalBankId = createdBankId;
} else {
if (!bankId) {
alert('Please select a bank.');
setIsSubmitting(false);
return;
}
finalBankId = String(bankId);
}
const createdBankId = await createBankAndGetId();
if (!createdBankId) {
throw new Error('Failed to create bank');
}
finalBankId = createdBankId;
} else {
if (!bankId) {
alert('Please select a bank.');
setIsSubmitting(false);
return;
}
finalBankId = String(bankId);
}
router.patch(
update.url(account.id),
{
name: displayName,
bank_id: finalBankId,
...(finalBankId ? { bank_id: finalBankId } : {}),
type: type,
currency_code: currencyCode,
},

View File

@ -683,7 +683,7 @@ export function ImportBalancesDrawer({
<div className="space-y-4">
<div className="flex items-center justify-between text-sm text-muted-foreground">
<span>
{importProgress} of {importTotal}
{importProgress} of {importTotal}{' '}
{isLoan
? __('owed amounts imported')
: __('balances imported')}

View File

@ -49,8 +49,8 @@ export function ImportBalanceStepAccount({
/>
<BankLogo
src={account.bank.logo}
name={account.bank.name}
src={account.bank?.logo ?? null}
name={account.bank?.name}
className="h-10 w-10"
fallback="icon"
/>
@ -62,8 +62,9 @@ export function ImportBalanceStepAccount({
/>
</span>
<span className="text-sm text-muted-foreground">
{account.bank.name} {' '}
{account.currency_code}
{account.bank?.name ??
account.currency_code}{' '}
{account.currency_code}
</span>
</div>
</Label>

View File

@ -16,6 +16,14 @@ import {
} from './chart-granularity-toggle';
import { ChartViewToggle } from './chart-view-toggle';
interface ToggleOption {
id: string;
label: string;
description: string;
checked: boolean;
onChange: (value: boolean) => void;
}
interface ChartSettingsPopoverProps {
granularity: ChartGranularity;
onGranularityChange: (value: ChartGranularity) => void;
@ -26,6 +34,7 @@ interface ChartSettingsPopoverProps {
includeLoansLabel?: string;
includeLoans?: boolean;
onIncludeLoansChange?: (value: boolean) => void;
toggles?: ToggleOption[];
}
export function ChartSettingsPopover({
@ -38,7 +47,29 @@ export function ChartSettingsPopover({
includeLoansLabel,
includeLoans,
onIncludeLoansChange,
toggles = [],
}: ChartSettingsPopoverProps) {
// Build the effective list of toggles: legacy loan prop + explicit toggles
const allToggles: ToggleOption[] = [];
if (
onIncludeLoansChange &&
typeof includeLoans === 'boolean' &&
includeLoansLabel
) {
allToggles.push({
id: 'include-loans-in-net-worth-chart',
label: includeLoansLabel,
description: __(
'Include loan balances in the net worth totals and chart',
),
checked: includeLoans,
onChange: onIncludeLoansChange,
});
}
allToggles.push(...toggles);
return (
<Popover>
<PopoverTrigger asChild>
@ -72,38 +103,35 @@ export function ChartSettingsPopover({
showTooltip={false}
/>
</div>
<Separator />
{allToggles.length > 0 && <Separator />}
</>
) : null}
{onIncludeLoansChange &&
typeof includeLoans === 'boolean' &&
includeLoansLabel ? (
<>
<div className="flex items-start justify-between gap-4">
<div className="space-y-1">
<Label
htmlFor="include-loans-in-net-worth-chart"
className="text-sm leading-5 font-medium"
>
{includeLoansLabel}
</Label>
<p className="text-xs text-muted-foreground">
{__(
'Include loan balances in the net worth totals and chart',
)}
</p>
</div>
<Checkbox
id="include-loans-in-net-worth-chart"
checked={includeLoans}
onCheckedChange={(checked) =>
onIncludeLoansChange(checked === true)
}
className="mt-0.5"
/>
{allToggles.map((toggle) => (
<div
key={toggle.id}
className="flex items-start justify-between gap-4"
>
<div className="space-y-1">
<Label
htmlFor={toggle.id}
className="text-sm leading-5 font-medium"
>
{toggle.label}
</Label>
<p className="text-xs text-muted-foreground">
{toggle.description}
</p>
</div>
</>
) : null}
<Checkbox
id={toggle.id}
checked={toggle.checked}
onCheckedChange={(checked) =>
toggle.onChange(checked === true)
}
className="mt-0.5"
/>
</div>
))}
</div>
</PopoverContent>
</Popover>

View File

@ -54,8 +54,8 @@ export function AccountBalanceCard({
className="-my-1 -ml-1.5 flex items-center rounded-md px-1.5 py-1 transition-colors hover:bg-muted"
>
<BankLogo
src={account.bank.logo}
name={account.bank.name}
src={account.bank?.logo ?? null}
name={account.bank?.name}
className="mr-2 inline-block size-5"
/>

View File

@ -142,6 +142,8 @@ export function NetWorthChart({
const [isDailyLoading, setIsDailyLoading] = useState(false);
const includeLoansInNetWorthChart =
props.includeLoansInNetWorthChart ?? true;
const includeRealEstateInNetWorthChart =
props.includeRealEstateInNetWorthChart ?? true;
const fetchDailyData = useCallback(async () => {
setIsDailyLoading(true);
@ -202,10 +204,19 @@ export function NetWorthChart({
const accounts = activeData.accounts || {};
const chartDataArray = activeData.data || [];
// All accounts included based on the loan toggle used for totals & trends.
// All accounts included based on the toggles used for totals & trends.
const includedAccounts = Object.fromEntries(
Object.entries(accounts).filter(([, account]) => {
return includeLoansInNetWorthChart || account.type !== 'loan';
if (!includeLoansInNetWorthChart && account.type === 'loan') {
return false;
}
if (
!includeRealEstateInNetWorthChart &&
account.type === 'real_estate'
) {
return false;
}
return true;
}),
);
@ -388,7 +399,11 @@ export function NetWorthChart({
accountsForHook: allHookAccounts,
hasLiabilities: hasLiabs,
};
}, [activeData, includeLoansInNetWorthChart]);
}, [
activeData,
includeLoansInNetWorthChart,
includeRealEstateInNetWorthChart,
]);
const chartViews = useChartViews({
data: rawChartData as Array<Record<string, string | number>>,
@ -397,6 +412,7 @@ export function NetWorthChart({
hasStackedView: true,
netWorthOptions: {
includeLoanAccounts: includeLoansInNetWorthChart,
includeRealEstateAccounts: includeRealEstateInNetWorthChart,
},
});
@ -414,6 +430,38 @@ export function NetWorthChart({
);
}, []);
const handleIncludeRealEstateChange = useCallback(
(includeRealEstate: boolean) => {
router.patch(
'/settings/net-worth-chart-real-estate-preference',
{
include_real_estate_in_net_worth_chart: includeRealEstate,
},
{
preserveScroll: true,
preserveState: true,
only: ['includeRealEstateInNetWorthChart'],
},
);
},
[],
);
const settingsToggles = useMemo(
() => [
{
id: 'include-real-estate-in-net-worth-chart',
label: __('Include real estate'),
description: __(
'Include real estate assets in the net worth totals and chart',
),
checked: includeRealEstateInNetWorthChart,
onChange: handleIncludeRealEstateChange,
},
],
[includeRealEstateInNetWorthChart, handleIncludeRealEstateChange],
);
const valueFormatter = useMemo(() => {
return (value: number): React.ReactNode => {
return (
@ -513,6 +561,7 @@ export function NetWorthChart({
includeLoansLabel={__('Include loans')}
includeLoans={includeLoansInNetWorthChart}
onIncludeLoansChange={handleIncludeLoansChange}
toggles={settingsToggles}
/>
) : (
<>
@ -538,6 +587,7 @@ export function NetWorthChart({
onIncludeLoansChange={
handleIncludeLoansChange
}
toggles={settingsToggles}
/>
</>
)}

View File

@ -49,7 +49,7 @@ export function CategoryCell({
(a) => a.id === transaction.account_id,
);
const bank = account?.bank?.id
? banks.find((b) => b.id === account.bank.id)
? banks.find((b) => b.id === account.bank!.id)
: undefined;
const updatedTransaction: DecryptedTransaction = {

View File

@ -3,7 +3,11 @@ import { BankLogo } from '@/components/bank-logo';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
import { filterTransactionalAccounts, type Account } from '@/types/account';
import {
filterTransactionalAccounts,
formatAccountType,
type Account,
} from '@/types/account';
import type { UUID } from '@/types/uuid';
import { __ } from '@/utils/i18n';
import { useEffect } from 'react';
@ -60,8 +64,8 @@ export function ImportStepAccount({
/>
<BankLogo
src={account.bank.logo}
name={account.bank.name}
src={account.bank?.logo ?? null}
name={account.bank?.name}
className="h-10 w-10"
fallback="icon"
/>
@ -73,8 +77,9 @@ export function ImportStepAccount({
/>
</span>
<span className="text-sm text-muted-foreground">
{account.bank.name} {' '}
{account.currency_code}
{account.bank?.name ??
formatAccountType(account.type)}{' '}
{account.currency_code}
</span>
</div>
</Label>

View File

@ -497,7 +497,7 @@ export function TransactionList({
? categoriesMap.get(transaction.category_id)
: null;
const bank = account?.bank?.id
? banksMap.get(account.bank.id)
? banksMap.get(account.bank!.id)
: undefined;
return {

View File

@ -54,6 +54,7 @@ export interface AccountInfo {
export interface NetWorthSeriesOptions {
includeLoanAccounts?: boolean;
includeRealEstateAccounts?: boolean;
}
/**
@ -70,9 +71,14 @@ export function computeNetWorthSeries(
}
const includeLoanAccounts = options.includeLoanAccounts ?? true;
const includeRealEstateAccounts = options.includeRealEstateAccounts ?? true;
const accountIds = Object.entries(accounts)
.filter(([, account]) => includeLoanAccounts || account.type !== 'loan')
.filter(
([, account]) =>
includeRealEstateAccounts || account.type !== 'real_estate',
)
.map(([id]) => id);
return data.map((point) => {

View File

@ -24,6 +24,7 @@ const ACCOUNT_TYPE_ORDER: AccountType[] = [
'savings',
'investment',
'retirement',
'real_estate',
'loan',
'credit_card',
'others',
@ -69,6 +70,7 @@ export default function AccountsIndex({ accounts, accountMetrics }: Props) {
savings: [],
investment: [],
retirement: [],
real_estate: [],
loan: [],
credit_card: [],
others: [],

View File

@ -1,4 +1,5 @@
import { index, show } from '@/actions/App/Http/Controllers/AccountController';
import { update as updateRealEstateDetail } from '@/actions/App/Http/Controllers/RealEstateDetailController';
import { AccountBalanceChart } from '@/components/accounts/account-balance-chart';
import { BalancesModal } from '@/components/accounts/balances-modal';
import { DeleteAccountDialog } from '@/components/accounts/delete-account-dialog';
@ -9,8 +10,11 @@ import { BankLogo } from '@/components/bank-logo';
import HeadingSmall from '@/components/heading-small';
import { MobileBackButton } from '@/components/mobile-back-button';
import { TransactionList } from '@/components/transactions/transaction-list';
import { AmountDisplay } from '@/components/ui/amount-display';
import { AmountInput } from '@/components/ui/amount-input';
import { Button } from '@/components/ui/button';
import { ButtonGroup } from '@/components/ui/button-group';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import {
DropdownMenu,
DropdownMenuContent,
@ -18,28 +22,51 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Textarea } from '@/components/ui/textarea';
import AppSidebarLayout from '@/layouts/app/app-sidebar-layout';
import { BreadcrumbItem } from '@/types';
import {
Account,
AREA_UNITS,
Bank,
formatAccountType,
formatAreaUnit,
formatPropertyType,
isTransactionalAccount,
PROPERTY_TYPES,
type AreaUnit,
type PropertyType,
type RealEstateDetail,
} from '@/types/account';
import { AutomationRule } from '@/types/automation-rule';
import { Category } from '@/types/category';
import { Label } from '@/types/label';
import { Label as LabelType } from '@/types/label';
import { formatDateMedium } from '@/utils/date';
import { __ } from '@/utils/i18n';
import { Head } from '@inertiajs/react';
import { ChevronDown } from 'lucide-react';
import { Head, router } from '@inertiajs/react';
import { ChevronDown, Pencil } from 'lucide-react';
import { useState } from 'react';
interface AccountWithRealEstate extends Account {
real_estate_detail?: RealEstateDetail;
available_loan_accounts?: Account[];
}
interface Props {
account: Account;
account: AccountWithRealEstate;
categories: Category[];
accounts: Account[];
banks: Bank[];
labels?: Label[];
labels?: LabelType[];
automationRules?: AutomationRule[];
}
@ -57,6 +84,7 @@ export default function AccountShow({
const [importBalancesOpen, setImportBalancesOpen] = useState(false);
const [balancesOpen, setBalancesOpen] = useState(false);
const [chartRefreshKey, setChartRefreshKey] = useState(0);
const [editingDetails, setEditingDetails] = useState(false);
function handleBalanceUpdated() {
setChartRefreshKey((prev) => prev + 1);
@ -64,6 +92,8 @@ export default function AccountShow({
const isConnected = !!account.banking_connection_id;
const isLoan = account.type === 'loan';
const isRealEstate = account.type === 'real_estate';
const realEstateDetail = account.real_estate_detail;
const breadcrumbs: BreadcrumbItem[] = [
{
@ -77,6 +107,24 @@ export default function AccountShow({
},
];
const updateBalanceLabel = isLoan
? __('Update owed amount')
: isRealEstate
? __('Update market value')
: __('Update balance');
const importBalancesLabel = isLoan
? __('Import owed amounts')
: isRealEstate
? __('Import market values')
: __('Import balances');
const seeBalancesLabel = isLoan
? __('See owed amounts')
: isRealEstate
? __('See market values')
: __('See balances');
return (
<AppSidebarLayout
breadcrumbs={breadcrumbs}
@ -87,15 +135,17 @@ export default function AccountShow({
<div className="space-y-6 p-6">
<div className="sm flex flex-col items-start justify-between gap-4 sm:flex-row sm:items-center">
<div className="flex items-center gap-4 pl-1">
<BankLogo
src={account.bank?.logo}
name={account.bank?.name}
className="size-12"
fallback="letter"
/>
{account.bank && (
<BankLogo
src={account.bank.logo}
name={account.bank.name}
className="size-12"
fallback="letter"
/>
)}
<HeadingSmall
title={account.name}
description={`${account.bank?.name || 'Unknown Bank'} · ${formatAccountType(account.type)}`}
description={`${account.bank ? `${account.bank.name} · ` : ''}${formatAccountType(account.type)}`}
/>
</div>
@ -113,9 +163,7 @@ export default function AccountShow({
variant="outline"
onClick={() => setUpdateBalanceOpen(true)}
>
{isLoan
? __('Update owed amount')
: __('Update balance')}
{updateBalanceLabel}
</Button>
</ButtonGroup>
<ButtonGroup>
@ -123,9 +171,7 @@ export default function AccountShow({
variant="outline"
onClick={() => setImportBalancesOpen(true)}
>
{isLoan
? __('Import owed amounts')
: __('Import balances')}
{importBalancesLabel}
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
@ -143,9 +189,7 @@ export default function AccountShow({
setBalancesOpen(true)
}
>
{isLoan
? __('See owed amounts')
: __('See balances')}
{seeBalancesLabel}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => setEditOpen(true)}
@ -176,6 +220,18 @@ export default function AccountShow({
}
/>
{isRealEstate && realEstateDetail && (
<PropertyDetailsCard
detail={realEstateDetail}
account={account}
availableLoanAccounts={
account.available_loan_accounts ?? []
}
isEditing={editingDetails}
onEditToggle={setEditingDetails}
/>
)}
{isTransactionalAccount(account) && (
<TransactionList
categories={categories}
@ -232,3 +288,395 @@ export default function AccountShow({
</AppSidebarLayout>
);
}
function PropertyDetailsCard({
detail,
account,
availableLoanAccounts,
isEditing,
onEditToggle,
}: {
detail: RealEstateDetail;
account: AccountWithRealEstate;
availableLoanAccounts: Account[];
isEditing: boolean;
onEditToggle: (editing: boolean) => void;
}) {
const [isSubmitting, setIsSubmitting] = useState(false);
const [formData, setFormData] = useState({
property_type: detail.property_type,
address: detail.address ?? '',
purchase_price: detail.purchase_price ?? 0,
purchase_date: detail.purchase_date ?? '',
area_value: detail.area_value ?? '',
area_unit: detail.area_unit ?? (null as AreaUnit | null),
linked_loan_account_id:
detail.linked_loan_account_id ?? (null as string | null),
notes: detail.notes ?? '',
});
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setIsSubmitting(true);
router.patch(
updateRealEstateDetail.url(account.id),
{
property_type: formData.property_type,
address: formData.address || null,
purchase_price: formData.purchase_price || null,
purchase_date: formData.purchase_date || null,
area_value: formData.area_value || null,
area_unit: formData.area_unit,
linked_loan_account_id: formData.linked_loan_account_id,
notes: formData.notes || null,
},
{
preserveScroll: true,
onSuccess: () => onEditToggle(false),
onFinish: () => setIsSubmitting(false),
},
);
}
function handleCancel() {
setFormData({
property_type: detail.property_type,
address: detail.address ?? '',
purchase_price: detail.purchase_price ?? 0,
purchase_date: detail.purchase_date ?? '',
area_value: detail.area_value ?? '',
area_unit: detail.area_unit ?? null,
linked_loan_account_id: detail.linked_loan_account_id ?? null,
notes: detail.notes ?? '',
});
onEditToggle(false);
}
if (isEditing) {
return (
<Card>
<CardHeader>
<CardTitle>{__('Edit Property Details')}</CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="edit_property_type">
{__('Property Type')}
</Label>
<Select
value={formData.property_type}
onValueChange={(value) =>
setFormData((prev) => ({
...prev,
property_type:
value as PropertyType,
}))
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{PROPERTY_TYPES.map((type) => (
<SelectItem key={type} value={type}>
{formatPropertyType(type)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="edit_purchase_date">
{__('Purchase Date')}
</Label>
<Input
id="edit_purchase_date"
type="date"
value={formData.purchase_date}
onChange={(e) =>
setFormData((prev) => ({
...prev,
purchase_date: e.target.value,
}))
}
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="edit_address">
{__('Address')}
</Label>
<Input
id="edit_address"
value={formData.address}
onChange={(e) =>
setFormData((prev) => ({
...prev,
address: e.target.value,
}))
}
placeholder={__('Property address')}
/>
</div>
<div className="space-y-2">
<Label htmlFor="edit_purchase_price">
{__('Purchase Price')}
</Label>
<AmountInput
id="edit_purchase_price"
value={formData.purchase_price}
onChange={(value) =>
setFormData((prev) => ({
...prev,
purchase_price: value,
}))
}
currencyCode={account.currency_code}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="edit_area_value">
{__('Area')}
</Label>
<Input
id="edit_area_value"
type="number"
value={formData.area_value}
onChange={(e) =>
setFormData((prev) => ({
...prev,
area_value: e.target.value,
}))
}
placeholder="0"
min="0"
step="0.01"
/>
</div>
<div className="space-y-2">
<Label htmlFor="edit_area_unit">
{__('Unit')}
</Label>
<Select
value={formData.area_unit ?? 'none'}
onValueChange={(value) =>
setFormData((prev) => ({
...prev,
area_unit:
value === 'none'
? null
: (value as AreaUnit),
}))
}
>
<SelectTrigger>
<SelectValue
placeholder={__('Select unit')}
/>
</SelectTrigger>
<SelectContent>
<SelectItem value="none">
{__('None')}
</SelectItem>
{AREA_UNITS.map((unit) => (
<SelectItem key={unit} value={unit}>
{formatAreaUnit(unit)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
{availableLoanAccounts.length > 0 && (
<div className="space-y-2">
<Label htmlFor="edit_linked_loan">
{__('Linked Mortgage / Loan')}
</Label>
<Select
value={
formData.linked_loan_account_id ??
'none'
}
onValueChange={(value) =>
setFormData((prev) => ({
...prev,
linked_loan_account_id:
value === 'none' ? null : value,
}))
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">
{__('No linked loan')}
</SelectItem>
{availableLoanAccounts.map((loan) => (
<SelectItem
key={loan.id}
value={loan.id}
>
{loan.name}{' '}
{loan.bank
? `(${loan.bank.name})`
: ''}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
<div className="space-y-2">
<Label htmlFor="edit_notes">{__('Notes')}</Label>
<Textarea
id="edit_notes"
value={formData.notes}
onChange={(e) =>
setFormData((prev) => ({
...prev,
notes: e.target.value,
}))
}
placeholder={__(
'Additional notes about this property',
)}
rows={3}
/>
</div>
<div className="flex justify-end gap-2">
<Button
type="button"
variant="outline"
onClick={handleCancel}
disabled={isSubmitting}
>
{__('Cancel')}
</Button>
<Button type="submit" disabled={isSubmitting}>
{isSubmitting ? __('Saving...') : __('Save')}
</Button>
</div>
</form>
</CardContent>
</Card>
);
}
const linkedLoan = detail.linked_loan_account;
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle>{__('Property Details')}</CardTitle>
<Button
variant="ghost"
size="sm"
onClick={() => onEditToggle(true)}
>
<Pencil className="mr-1 h-3.5 w-3.5" />
{__('Edit')}
</Button>
</CardHeader>
<CardContent>
<dl className="grid gap-4 sm:grid-cols-2">
<div>
<dt className="text-sm text-muted-foreground">
{__('Property Type')}
</dt>
<dd className="font-medium">
{formatPropertyType(detail.property_type)}
</dd>
</div>
{detail.address && (
<div>
<dt className="text-sm text-muted-foreground">
{__('Address')}
</dt>
<dd className="font-medium">{detail.address}</dd>
</div>
)}
{detail.purchase_price !== null && (
<div>
<dt className="text-sm text-muted-foreground">
{__('Purchase Price')}
</dt>
<dd className="font-medium">
<AmountDisplay
amountInCents={detail.purchase_price}
currencyCode={account.currency_code}
/>
</dd>
</div>
)}
{detail.purchase_date && (
<div>
<dt className="text-sm text-muted-foreground">
{__('Purchase Date')}
</dt>
<dd className="font-medium">
{formatDateMedium(detail.purchase_date)}
</dd>
</div>
)}
{detail.area_value && detail.area_unit && (
<div>
<dt className="text-sm text-muted-foreground">
{__('Area')}
</dt>
<dd className="font-medium">
{detail.area_value}{' '}
{formatAreaUnit(detail.area_unit)}
</dd>
</div>
)}
{linkedLoan && (
<div>
<dt className="text-sm text-muted-foreground">
{__('Linked Mortgage / Loan')}
</dt>
<dd className="font-medium">
<a
href={show.url(linkedLoan.id)}
className="text-primary underline-offset-4 hover:underline"
>
{linkedLoan.name}
{linkedLoan.bank
? ` (${linkedLoan.bank.name})`
: ''}
</a>
</dd>
</div>
)}
</dl>
{detail.notes && (
<div className="mt-4 border-t pt-4">
<dt className="text-sm text-muted-foreground">
{__('Notes')}
</dt>
<dd className="mt-1 text-sm whitespace-pre-wrap">
{detail.notes}
</dd>
</div>
)}
</CardContent>
</Card>
);
}

View File

@ -228,6 +228,13 @@ export default function Accounts({ accounts }: AccountsPageProps) {
header: () => __('Bank'),
cell: ({ row }) => {
const bank = row.original.bank;
if (!bank) {
return (
<div className="min-w-32 text-sm text-muted-foreground">
</div>
);
}
return (
<div className="flex min-w-32 items-center gap-2">
<BankLogo

View File

@ -4,6 +4,7 @@ import {
Building2,
CreditCard,
FolderKanban,
Home,
LineChart,
LucideIcon,
PiggyBank,
@ -17,6 +18,7 @@ export const ACCOUNT_TYPES = [
'credit_card',
'investment',
'loan',
'real_estate',
'retirement',
'savings',
'others',
@ -51,7 +53,7 @@ export interface Account {
name: string;
name_iv: string | null;
encrypted: boolean;
bank: Bank;
bank: Bank | null;
type: AccountType;
currency_code: CurrencyCode;
banking_connection_id: UUID | null;
@ -69,12 +71,61 @@ export interface AccountBalance {
updated_at: string;
}
export const PROPERTY_TYPES = [
'residential',
'commercial',
'land',
'vacation',
'other',
] as const;
export type PropertyType = (typeof PROPERTY_TYPES)[number];
export const AREA_UNITS = ['sqm', 'sqft', 'acres', 'hectares'] as const;
export type AreaUnit = (typeof AREA_UNITS)[number];
export interface RealEstateDetail {
id: UUID;
property_type: PropertyType;
address: string | null;
purchase_price: number | null;
purchase_date: string | null;
area_value: string | null;
area_unit: AreaUnit | null;
notes: string | null;
linked_loan_account_id: UUID | null;
linked_loan_account: Account | null;
}
export function formatPropertyType(type: PropertyType): string {
const typeMap: Record<PropertyType, string> = {
residential: __('Residential'),
commercial: __('Commercial'),
land: __('Land'),
vacation: __('Vacation'),
other: __('Other'),
};
return typeMap[type] || type;
}
export function formatAreaUnit(unit: AreaUnit): string {
const unitMap: Record<AreaUnit, string> = {
sqm: __('m²'),
sqft: __('ft²'),
acres: __('acres'),
hectares: __('ha'),
};
return unitMap[unit] || unit;
}
export function formatAccountType(type: AccountType): string {
const typeMap: Record<AccountType, string> = {
checking: __('Checking'),
credit_card: __('Credit Card'),
investment: __('Investment'),
loan: __('Loan'),
real_estate: __('Real Estate'),
retirement: __('Retirement / Pension'),
savings: __('Savings'),
others: __('Others'),
@ -84,6 +135,7 @@ export function formatAccountType(type: AccountType): string {
const NON_TRANSACTIONAL_ACCOUNT_TYPES: AccountType[] = [
'investment',
'real_estate',
'retirement',
];
@ -109,6 +161,7 @@ export function accountIconByType(type: AccountType): LucideIcon {
credit_card: CreditCard,
investment: LineChart,
loan: Building2,
real_estate: Home,
retirement: TrendingUp,
savings: PiggyBank,
others: FolderKanban,
@ -129,9 +182,13 @@ export function isLoanAccount(account: Pick<Account, 'type'>): boolean {
return account.type === 'loan';
}
export function isRealEstateAccount(account: Pick<Account, 'type'>): boolean {
return account.type === 'real_estate';
}
/**
* Returns the appropriate term for "balance" based on account type.
* Loan accounts use "owed amount" instead of "balance".
* Loan accounts use "owed amount", real estate uses "market value".
*/
export function balanceTerm(
type: AccountType,
@ -140,12 +197,15 @@ export function balanceTerm(
if (type === 'loan') {
return variant === 'plural' ? __('owed amounts') : __('owed amount');
}
if (type === 'real_estate') {
return variant === 'plural' ? __('market values') : __('market value');
}
return variant === 'plural' ? __('balances') : __('balance');
}
/**
* Returns the appropriate capitalized term for "Balance" based on account type.
* Loan accounts use "Owed Amount" instead of "Balance".
* Loan accounts use "Owed Amount", real estate uses "Market Value".
*/
export function balanceTermCapitalized(
type: AccountType,
@ -154,5 +214,8 @@ export function balanceTermCapitalized(
if (type === 'loan') {
return variant === 'plural' ? __('Owed Amounts') : __('Owed Amount');
}
if (type === 'real_estate') {
return variant === 'plural' ? __('Market Values') : __('Market Value');
}
return variant === 'plural' ? __('Balances') : __('Balance');
}

View File

@ -43,6 +43,7 @@ export interface Features {
cashflow: boolean;
'open-banking': boolean;
'account-mapping': boolean;
'real-estate': boolean;
}
export interface Flash {
@ -61,6 +62,7 @@ export interface SharedData {
flash: Flash;
chartColorScheme: ChartColorScheme;
includeLoansInNetWorthChart: boolean;
includeRealEstateInNetWorthChart: boolean;
subscriptionsEnabled: boolean;
pricing: PricingConfig;
sidebarOpen: boolean;

View File

@ -7,6 +7,7 @@ use App\Http\Controllers\Settings\CategoryController;
use App\Http\Controllers\Settings\ChartColorSchemeController;
use App\Http\Controllers\Settings\LabelController;
use App\Http\Controllers\Settings\NetWorthChartLoanPreferenceController;
use App\Http\Controllers\Settings\NetWorthChartRealEstatePreferenceController;
use App\Http\Controllers\Settings\PasswordController;
use App\Http\Controllers\Settings\ProfileController;
use App\Http\Controllers\Settings\TwoFactorAuthenticationController;
@ -65,6 +66,9 @@ Route::middleware('auth')->group(function () {
Route::patch('settings/net-worth-chart-loan-preference', [NetWorthChartLoanPreferenceController::class, 'update'])
->name('net-worth-chart-loan-preference.update');
Route::patch('settings/net-worth-chart-real-estate-preference', [NetWorthChartRealEstatePreferenceController::class, 'update'])
->name('net-worth-chart-real-estate-preference.update');
Route::get('settings/billing', [SubscriptionController::class, 'billing'])->name('settings.billing');
Route::get('settings/billing/portal', [SubscriptionController::class, 'billingPortal'])->name('settings.billing.portal');

View File

@ -11,6 +11,7 @@ use App\Http\Controllers\OpenBanking\BinanceController;
use App\Http\Controllers\OpenBanking\BitpandaController;
use App\Http\Controllers\OpenBanking\IndexaCapitalController;
use App\Http\Controllers\OpenBanking\InstitutionController;
use App\Http\Controllers\RealEstateDetailController;
use App\Http\Controllers\ReEvaluateTransactionRulesController;
use App\Http\Controllers\RobotsController;
use App\Http\Controllers\SitemapController;
@ -98,6 +99,7 @@ Route::middleware(['auth', 'verified', 'onboarded', 'subscribed'])->group(functi
Route::get('accounts', [AccountController::class, 'index'])->name('accounts.list');
Route::get('accounts/{account}', [AccountController::class, 'show'])->name('accounts.show');
Route::patch('accounts/{account}/real-estate-detail', [RealEstateDetailController::class, 'update'])->name('accounts.real-estate-detail.update');
Route::get('transactions', [TransactionController::class, 'index'])->name('transactions.index');
Route::get('transactions/categorize', [TransactionController::class, 'categorize'])->name('transactions.categorize');

View File

@ -0,0 +1,87 @@
<?php
use App\Enums\AccountType;
use App\Enums\PropertyType;
use App\Models\User;
use Laravel\Pennant\Feature;
test('users without real-estate feature cannot create real estate accounts', function () {
$user = User::factory()->onboarded()->create();
Feature::for($user)->deactivate('real-estate');
$response = $this->actingAs($user)->post(route('accounts.store'), [
'name' => 'My Property',
'currency_code' => 'EUR',
'type' => AccountType::RealEstate->value,
'property_type' => PropertyType::Residential->value,
]);
$response->assertForbidden();
});
test('users with real-estate feature can create real estate accounts', function () {
$user = User::factory()->onboarded()->create();
Feature::for($user)->activate('real-estate');
$response = $this->actingAs($user)->post(route('accounts.store'), [
'name' => 'My Property',
'currency_code' => 'EUR',
'type' => AccountType::RealEstate->value,
'property_type' => PropertyType::Residential->value,
]);
$response->assertSessionHasNoErrors()->assertRedirect();
});
test('users without real-estate feature can still create non-real-estate accounts', function () {
$user = User::factory()->onboarded()->create();
$bank = \App\Models\Bank::factory()->create();
Feature::for($user)->deactivate('real-estate');
$response = $this->actingAs($user)->post(route('accounts.store'), [
'name' => 'My Savings',
'bank_id' => $bank->id,
'currency_code' => 'EUR',
'type' => AccountType::Savings->value,
]);
$response->assertSessionHasNoErrors()->assertRedirect();
});
test('real-estate feature flag is shared with frontend when enabled', function () {
$user = User::factory()->onboarded()->create();
Feature::for($user)->activate('real-estate');
$response = $this->actingAs($user)->get('/dashboard');
$response->assertOk();
$response->assertInertia(fn ($page) => $page
->where('features.real-estate', true)
);
});
test('real-estate feature flag is shared with frontend when disabled', function () {
$user = User::factory()->onboarded()->create();
Feature::for($user)->deactivate('real-estate');
$response = $this->actingAs($user)->get('/dashboard');
$response->assertOk();
$response->assertInertia(fn ($page) => $page
->where('features.real-estate', false)
);
});
test('guests see real-estate feature as false', function () {
$response = $this->get('/');
$response->assertOk();
$response->assertInertia(fn ($page) => $page
->where('features.real-estate', false)
);
});

View File

@ -0,0 +1,594 @@
<?php
use App\Enums\AccountType;
use App\Enums\PropertyType;
use App\Models\Account;
use App\Models\Bank;
use App\Models\RealEstateDetail;
use App\Models\User;
use Laravel\Pennant\Feature;
use function Pest\Laravel\actingAs;
use function Pest\Laravel\assertDatabaseHas;
beforeEach(function () {
$this->user = User::factory()->onboarded()->create();
$this->bank = Bank::factory()->create();
Feature::for($this->user)->activate('real-estate');
});
// -------------------------------------------------------------------
// 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('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('requires 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->assertSessionHasErrors(['bank_id']);
});
// -------------------------------------------------------------------
// 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',
]);
Account::factory()->realEstate()->create([
'user_id' => $this->user->id,
'name' => 'Beach House',
]);
Account::factory()->create([
'user_id' => $this->user->id,
'type' => AccountType::Checking,
'name' => 'Main Account',
]);
$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]);
});

View File

@ -0,0 +1,85 @@
<?php
use App\Models\User;
use App\Models\UserSetting;
test('net worth chart real estate preference can be updated', function () {
$user = User::factory()->create();
$response = $this
->actingAs($user)
->patch(route('net-worth-chart-real-estate-preference.update'), [
'include_real_estate_in_net_worth_chart' => false,
]);
$response->assertSessionHasNoErrors()->assertRedirect();
expect($user->fresh()->setting->include_real_estate_in_net_worth_chart)->toBeFalse();
});
test('net worth chart real estate preference rejects invalid values', function () {
$user = User::factory()->create();
$response = $this
->actingAs($user)
->patch(route('net-worth-chart-real-estate-preference.update'), [
'include_real_estate_in_net_worth_chart' => 'sometimes',
]);
$response->assertSessionHasErrors('include_real_estate_in_net_worth_chart');
});
test('net worth chart real estate preference requires authentication', function () {
$response = $this->patch(route('net-worth-chart-real-estate-preference.update'), [
'include_real_estate_in_net_worth_chart' => false,
]);
$response->assertRedirect(route('login'));
});
test('net worth chart real estate preference creates setting when none exists', function () {
$user = User::factory()->create();
expect(UserSetting::where('user_id', $user->id)->exists())->toBeFalse();
$this->actingAs($user)
->patch(route('net-worth-chart-real-estate-preference.update'), [
'include_real_estate_in_net_worth_chart' => false,
]);
expect(UserSetting::where('user_id', $user->id)->exists())->toBeTrue();
expect($user->fresh()->setting->include_real_estate_in_net_worth_chart)->toBeFalse();
});
test('net worth chart real estate preference updates existing setting', function () {
$user = User::factory()->create();
UserSetting::factory()->for($user)->create([
'include_real_estate_in_net_worth_chart' => false,
]);
$this->actingAs($user)
->patch(route('net-worth-chart-real-estate-preference.update'), [
'include_real_estate_in_net_worth_chart' => true,
]);
expect($user->fresh()->setting->include_real_estate_in_net_worth_chart)->toBeTrue();
});
test('net worth chart real estate preference defaults to true when no setting exists', function () {
$user = User::factory()->create();
expect($user->setting)->toBeNull();
expect($user->setting?->include_real_estate_in_net_worth_chart ?? true)->toBeTrue();
});
test('net worth chart real estate preference is shared via inertia', function () {
$user = User::factory()->create();
UserSetting::factory()->for($user)->create([
'include_real_estate_in_net_worth_chart' => false,
]);
$response = $this->actingAs($user)->get(route('appearance.edit'));
$response->assertOk();
$response->assertInertia(fn ($page) => $page->where('includeRealEstateInNetWorthChart', false));
});

View File

@ -20,5 +20,42 @@ it('does not support invested amount for non-investment account types', function
'checking' => AccountType::Checking,
'credit card' => AccountType::CreditCard,
'loan' => AccountType::Loan,
'real estate' => AccountType::RealEstate,
'others' => AccountType::Others,
]);
it('reduces net worth for liability account types', function (AccountType $type) {
expect($type->reducesNetWorth())->toBeTrue();
})->with([
'credit card' => AccountType::CreditCard,
'loan' => AccountType::Loan,
]);
it('does not reduce net worth for asset account types', function (AccountType $type) {
expect($type->reducesNetWorth())->toBeFalse();
})->with([
'checking' => AccountType::Checking,
'savings' => AccountType::Savings,
'investment' => AccountType::Investment,
'retirement' => AccountType::Retirement,
'real estate' => AccountType::RealEstate,
'others' => AccountType::Others,
]);
it('is non-transactional for balance-only account types', function (AccountType $type) {
expect($type->isNonTransactional())->toBeTrue();
})->with([
'investment' => AccountType::Investment,
'retirement' => AccountType::Retirement,
'real estate' => AccountType::RealEstate,
]);
it('is transactional for standard account types', function (AccountType $type) {
expect($type->isNonTransactional())->toBeFalse();
})->with([
'checking' => AccountType::Checking,
'savings' => AccountType::Savings,
'credit card' => AccountType::CreditCard,
'loan' => AccountType::Loan,
'others' => AccountType::Others,
]);