From bb65bdc16e2f0952ec7508dbce418d0155715077 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADctor=20Falc=C3=B3n?= Date: Thu, 26 Mar 2026 14:06:09 +0000 Subject: [PATCH] feat(accounts): add loan amortization projections for loan accounts (#246) ## Summary - Adds loan detail tracking (interest rate, term, start date, original amount) to loan-type accounts, following the existing `real_estate_details` pattern - Implements `LoanAmortizationService` with standard amortization math to automatically project month-to-month balance evolution - Shows projected future balances as a dashed line on the account balance chart, visually distinct from historical data - Adds a scheduled command (`loans:generate-balances`) to auto-generate monthly balance entries for loan accounts - Includes 36 tests (10 unit for pure math, 26 feature for CRUD, API projections, command, and authorization) ## Changes ### Backend - **Migration**: `loan_details` table (UUID PK, unique `account_id` FK, interest rate, term, start date, original amount) - **LoanDetail model** + factory with `Account::loanDetail()` HasOne relationship - **LoanAmortizationService**: `calculateMonthlyPayment`, `calculateRemainingBalance`, `generateProjection`, `projectFromBalance`, `calculateRemainingMonths`, `getBalanceAtDate` - **GenerateMonthlyLoanBalances** command: runs monthly on 1st, skips existing entries - **LoanDetailController**: PATCH endpoint for editing loan details from show page - **Settings\AccountController**: creates/updates `LoanDetail` on store/update - **DashboardAnalyticsController**: appends projected data points with `projected: true` flag - **Validation**: loan-specific rules in StoreAccountRequest and UpdateAccountRequest ### Frontend - **AccountForm**: loan fields (rate, term, start date, original amount) shown when `type === 'loan'` - **Create/Edit dialogs**: pass loan data fields in POST/PATCH payloads - **Show page**: `LoanDetailsCard` component with edit/read modes, computed monthly payment and remaining months - **Balance chart**: new `ComposedChart` branch with dashed `Line` overlay for projected data points - **Types**: `LoanDetail` interface in `account.ts` --- .../Commands/GenerateMonthlyLoanBalances.php | 70 ++ app/Http/Controllers/AccountController.php | 40 +- .../Api/DashboardAnalyticsController.php | 28 + app/Http/Controllers/LoanDetailController.php | 46 + .../Settings/AccountController.php | 87 +- .../Requests/Settings/StoreAccountRequest.php | 11 + .../Settings/UpdateAccountRequest.php | 47 +- app/Http/Requests/UpdateLoanDetailRequest.php | 32 + app/Models/Account.php | 6 + app/Models/LoanDetail.php | 46 + app/Models/RealEstateDetail.php | 2 + app/Services/LoanAmortizationService.php | 216 +++++ database/factories/AccountFactory.php | 7 + database/factories/LoanDetailFactory.php | 30 + ...03_25_132244_create_loan_details_table.php | 32 + lang/es.json | 18 + .../accounts/account-balance-chart.tsx | 105 ++- .../js/components/accounts/account-form.tsx | 133 ++- .../accounts/create-account-dialog.tsx | 16 + .../accounts/edit-account-dialog.tsx | 115 ++- resources/js/pages/Accounts/Show.tsx | 315 ++++++- resources/js/types/account.ts | 11 + routes/console.php | 1 + routes/web.php | 2 + tests/Feature/LoanTest.php | 823 ++++++++++++++++++ tests/Feature/RealEstateTest.php | 103 +++ .../Services/LoanAmortizationServiceTest.php | 97 +++ 27 files changed, 2417 insertions(+), 22 deletions(-) create mode 100644 app/Console/Commands/GenerateMonthlyLoanBalances.php create mode 100644 app/Http/Controllers/LoanDetailController.php create mode 100644 app/Http/Requests/UpdateLoanDetailRequest.php create mode 100644 app/Models/LoanDetail.php create mode 100644 app/Services/LoanAmortizationService.php create mode 100644 database/factories/LoanDetailFactory.php create mode 100644 database/migrations/2026_03_25_132244_create_loan_details_table.php create mode 100644 tests/Feature/LoanTest.php create mode 100644 tests/Unit/Services/LoanAmortizationServiceTest.php diff --git a/app/Console/Commands/GenerateMonthlyLoanBalances.php b/app/Console/Commands/GenerateMonthlyLoanBalances.php new file mode 100644 index 00000000..21efaff7 --- /dev/null +++ b/app/Console/Commands/GenerateMonthlyLoanBalances.php @@ -0,0 +1,70 @@ +info('Generating monthly loan balances...'); + + $accounts = Account::query() + ->where('type', AccountType::Loan) + ->whereHas('loanDetail') + ->with('loanDetail') + ->get(); + + $generatedCount = 0; + $skippedCount = 0; + + $balanceDate = now()->startOfMonth()->toDateString(); + + foreach ($accounts as $account) { + $loanDetail = $account->loanDetail; + + $existingBalance = AccountBalance::query() + ->where('account_id', $account->id) + ->where('balance_date', $balanceDate) + ->exists(); + + if ($existingBalance) { + $skippedCount++; + + continue; + } + + $projectedBalance = $this->amortizationService->getBalanceAtDate( + $loanDetail, + now(), + ); + + AccountBalance::create([ + 'account_id' => $account->id, + 'balance_date' => $balanceDate, + 'balance' => $projectedBalance, + ]); + + $generatedCount++; + $this->info("Generated balance for: {$account->name} ({$projectedBalance} cents)"); + } + + $this->info("Generated {$generatedCount} balance entries, skipped {$skippedCount} (already exist)"); + + return Command::SUCCESS; + } +} diff --git a/app/Http/Controllers/AccountController.php b/app/Http/Controllers/AccountController.php index 8bc23447..1dd1e423 100644 --- a/app/Http/Controllers/AccountController.php +++ b/app/Http/Controllers/AccountController.php @@ -6,6 +6,7 @@ use App\Enums\AccountType; use App\Models\Account; use App\Models\AccountBalance; use App\Services\AccountMetricsService; +use App\Services\LoanAmortizationService; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Http\Request; use Inertia\Inertia; @@ -15,7 +16,10 @@ class AccountController extends Controller { use AuthorizesRequests; - public function __construct(private AccountMetricsService $accountMetricsService) {} + public function __construct( + private AccountMetricsService $accountMetricsService, + private LoanAmortizationService $loanAmortizationService, + ) {} public function index(Request $request): Response { @@ -52,9 +56,10 @@ class AccountController extends Controller $data['real_estate_detail'] = [ ...$realEstateDetail->only([ 'id', 'property_type', 'address', 'purchase_price', - 'purchase_date', 'area_value', 'area_unit', 'notes', - 'linked_loan_account_id', + 'area_value', 'area_unit', 'notes', + 'revaluation_percentage', 'linked_loan_account_id', ]), + 'purchase_date' => $realEstateDetail->purchase_date?->format('Y-m-d'), 'linked_loan_account' => $linkedLoan ? $linkedLoan->only(['id', 'name', 'name_iv', 'encrypted', 'type', 'currency_code', 'bank']) : null, @@ -84,6 +89,35 @@ class AccountController extends Controller ->get(['id', 'name', 'name_iv', 'encrypted', 'bank_id', 'type', 'currency_code']); } + if ($account->type === AccountType::Loan) { + $account->load('loanDetail'); + $loanDetail = $account->loanDetail; + + if ($loanDetail) { + $remainingMonths = $this->loanAmortizationService->calculateRemainingMonths($loanDetail, now()); + + $lastBalance = AccountBalance::query() + ->where('account_id', $account->id) + ->orderBy('balance_date', 'desc') + ->value('balance'); + + $monthlyPayment = $this->loanAmortizationService->calculateMonthlyPayment( + $lastBalance ?? $loanDetail->original_amount, + (float) $loanDetail->annual_interest_rate, + $lastBalance ? $remainingMonths : $loanDetail->loan_term_months, + ); + + $data['loan_detail'] = [ + ...$loanDetail->only([ + 'id', 'annual_interest_rate', 'loan_term_months', + 'start_date', 'original_amount', + ]), + 'monthly_payment' => $monthlyPayment, + 'remaining_months' => $remainingMonths, + ]; + } + } + return Inertia::render('Accounts/Show', [ 'account' => $data, ]); diff --git a/app/Http/Controllers/Api/DashboardAnalyticsController.php b/app/Http/Controllers/Api/DashboardAnalyticsController.php index 7ccc2584..d5a4b7a2 100644 --- a/app/Http/Controllers/Api/DashboardAnalyticsController.php +++ b/app/Http/Controllers/Api/DashboardAnalyticsController.php @@ -11,6 +11,7 @@ use App\Models\Transaction; use App\Services\AccountMetricsService; use App\Services\BalanceLookup; use App\Services\ExchangeRateService; +use App\Services\LoanAmortizationService; use App\Services\PeriodComparator; use Carbon\Carbon; use Illuminate\Http\Request; @@ -21,6 +22,7 @@ class DashboardAnalyticsController extends Controller public function __construct( private ExchangeRateService $exchangeRateService, private AccountMetricsService $accountMetricsService, + private LoanAmortizationService $loanAmortizationService, ) {} public function netWorth(Request $request) @@ -139,6 +141,32 @@ class DashboardAnalyticsController extends Controller $current->addMonth(); } + // Append projected future months for loan accounts with loan details + if ($account->type === AccountType::Loan) { + $loanDetail = $account->loanDetail; + + if ($loanDetail) { + $projection = $this->loanAmortizationService->generateProjection($loanDetail, 12); + $now = Carbon::now(); + + foreach ($projection as $yearMonth => $balanceCents) { + $projectedDate = Carbon::createFromFormat('Y-m', $yearMonth)->endOfMonth(); + + // Only add future months that are beyond the current date + if ($projectedDate->lte($now)) { + continue; + } + + $points[] = [ + 'month' => $yearMonth, + 'timestamp' => $projectedDate->timestamp, + 'value' => $balanceCents, + 'projected' => true, + ]; + } + } + } + return response()->json([ 'data' => $points, 'account' => [ diff --git a/app/Http/Controllers/LoanDetailController.php b/app/Http/Controllers/LoanDetailController.php new file mode 100644 index 00000000..c5e641f9 --- /dev/null +++ b/app/Http/Controllers/LoanDetailController.php @@ -0,0 +1,46 @@ +authorize('update', $account); + + $loanDetail = $account->loanDetail; + $data = $request->validated(); + + if (! $loanDetail) { + $required = ['annual_interest_rate', 'loan_term_months', 'start_date', 'original_amount']; + $missing = array_filter($required, fn (string $field): bool => ! isset($data[$field])); + + if (! empty($missing)) { + $errors = []; + foreach ($missing as $field) { + $errors[$field] = __('This field is required.'); + } + + return to_route('accounts.show', $account)->withErrors($errors); + } + + $account->loanDetail()->create($data); + + return to_route('accounts.show', $account); + } + + $loanDetail->update($data); + + return to_route('accounts.show', $account); + } +} diff --git a/app/Http/Controllers/Settings/AccountController.php b/app/Http/Controllers/Settings/AccountController.php index f0ec307e..13d48fcc 100644 --- a/app/Http/Controllers/Settings/AccountController.php +++ b/app/Http/Controllers/Settings/AccountController.php @@ -24,7 +24,7 @@ class AccountController extends Controller { $accounts = auth()->user() ->accounts() - ->with('bank:id,name,logo') + ->with(['bank:id,name,logo', 'loanDetail']) ->orderBy('name') ->get(['id', 'name', 'name_iv', 'encrypted', 'bank_id', 'type', 'currency_code', 'banking_connection_id']); @@ -72,6 +72,26 @@ class AccountController extends Controller } } + // Create loan detail if account type is loan and loan fields are provided + if ($account->type === AccountType::Loan) { + $loanData = collect($validated)->only([ + 'annual_interest_rate', 'loan_term_months', 'original_amount', + ])->filter(fn ($value) => $value !== null)->toArray(); + + $loanStartDate = $validated['loan_start_date'] ?? null; + if ($loanStartDate) { + $loanData['start_date'] = $loanStartDate; + } + + if (! empty($loanData) && isset($loanData['annual_interest_rate'], $loanData['loan_term_months'], $loanData['original_amount'])) { + if (! isset($loanData['start_date'])) { + $loanData['start_date'] = now()->toDateString(); + } + + $account->loanDetail()->create($loanData); + } + } + // Set user's currency_code from first account if ($user->accounts()->count() === 1) { $user->update(['currency_code' => $account->currency_code]); @@ -91,12 +111,75 @@ class AccountController extends Controller { $this->authorize('update', $account); + $validated = $request->validated(); + + $accountData = collect($validated)->only([ + 'name', 'bank_id', 'currency_code', 'type', + ])->toArray(); + $account->update([ - ...$request->validated(), + ...$accountData, 'encrypted' => false, 'name_iv' => null, ]); + // Update or create real estate detail if account type is real_estate + if ($account->type === AccountType::RealEstate) { + $realEstateData = collect($validated)->only([ + 'property_type', 'address', 'purchase_price', 'purchase_date', + 'area_value', 'area_unit', 'linked_loan_account_id', 'notes', + 'revaluation_percentage', + ])->filter(fn ($value) => $value !== null)->toArray(); + + if (! empty($realEstateData)) { + $account->realEstateDetail()->updateOrCreate( + ['account_id' => $account->id], + $realEstateData, + ); + } + } + + // Update or create loan detail if account type is loan + if ($account->type === AccountType::Loan) { + $loanData = collect($validated)->only([ + 'annual_interest_rate', 'loan_term_months', 'original_amount', + ])->filter(fn ($value) => $value !== null)->toArray(); + + $loanStartDate = $validated['loan_start_date'] ?? null; + if ($loanStartDate) { + $loanData['start_date'] = $loanStartDate; + } + + if (! empty($loanData)) { + $existingLoanDetail = $account->loanDetail; + + if ($existingLoanDetail) { + $existingLoanDetail->update($loanData); + } elseif (isset($loanData['annual_interest_rate'], $loanData['loan_term_months'], $loanData['original_amount'])) { + if (! isset($loanData['start_date'])) { + $loanData['start_date'] = now()->toDateString(); + } + + $account->loanDetail()->create($loanData); + } else { + $errors = []; + $requiredFields = [ + 'annual_interest_rate' => 'annual_interest_rate', + 'loan_term_months' => 'loan_term_months', + 'original_amount' => 'original_amount', + ]; + + foreach ($requiredFields as $field => $errorKey) { + if (! isset($loanData[$field])) { + $errors[$errorKey] = __('This field is required.'); + } + } + + return to_route('accounts.index')->withErrors($errors); + } + } + } + return to_route('accounts.index'); } diff --git a/app/Http/Requests/Settings/StoreAccountRequest.php b/app/Http/Requests/Settings/StoreAccountRequest.php index d977ed16..099efe40 100644 --- a/app/Http/Requests/Settings/StoreAccountRequest.php +++ b/app/Http/Requests/Settings/StoreAccountRequest.php @@ -75,6 +75,17 @@ class StoreAccountRequest extends FormRequest ]); } + $isLoan = $this->input('type') === AccountType::Loan->value; + + if ($isLoan) { + $rules = array_merge($rules, [ + 'annual_interest_rate' => ['nullable', 'numeric', 'min:0', 'max:100'], + 'loan_term_months' => ['nullable', 'integer', 'min:1', 'max:600'], + 'loan_start_date' => ['nullable', 'date'], + 'original_amount' => ['nullable', 'integer', 'min:0'], + ]); + } + return $rules; } } diff --git a/app/Http/Requests/Settings/UpdateAccountRequest.php b/app/Http/Requests/Settings/UpdateAccountRequest.php index 9ff0b3c7..eb0b04f2 100644 --- a/app/Http/Requests/Settings/UpdateAccountRequest.php +++ b/app/Http/Requests/Settings/UpdateAccountRequest.php @@ -3,6 +3,7 @@ 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; @@ -24,9 +25,13 @@ class UpdateAccountRequest 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', @@ -38,5 +43,43 @@ class UpdateAccountRequest extends FormRequest Rule::in(array_map(fn ($type) => $type->value, AccountType::cases())), ], ]; + + 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'], + 'revaluation_percentage' => ['nullable', 'numeric', 'min:-100', 'max:100'], + ]); + } + + $isLoan = $this->input('type') === AccountType::Loan->value; + + if ($isLoan) { + $rules = array_merge($rules, [ + 'annual_interest_rate' => ['nullable', 'numeric', 'min:0', 'max:100'], + 'loan_term_months' => ['nullable', 'integer', 'min:1', 'max:600'], + 'loan_start_date' => ['nullable', 'date'], + 'original_amount' => ['nullable', 'integer', 'min:0'], + ]); + } + + return $rules; } } diff --git a/app/Http/Requests/UpdateLoanDetailRequest.php b/app/Http/Requests/UpdateLoanDetailRequest.php new file mode 100644 index 00000000..b45b603f --- /dev/null +++ b/app/Http/Requests/UpdateLoanDetailRequest.php @@ -0,0 +1,32 @@ +|string> + */ + public function rules(): array + { + return [ + 'annual_interest_rate' => ['sometimes', 'required', 'numeric', 'min:0', 'max:100'], + 'loan_term_months' => ['sometimes', 'required', 'integer', 'min:1', 'max:600'], + 'start_date' => ['sometimes', 'required', 'date'], + 'original_amount' => ['sometimes', 'required', 'integer', 'min:0'], + ]; + } +} diff --git a/app/Models/Account.php b/app/Models/Account.php index 0a404267..d3f18367 100644 --- a/app/Models/Account.php +++ b/app/Models/Account.php @@ -79,6 +79,12 @@ class Account extends Model return $this->hasOne(RealEstateDetail::class); } + /** @return HasOne */ + public function loanDetail(): HasOne + { + return $this->hasOne(LoanDetail::class); + } + public function isConnected(): bool { return $this->banking_connection_id !== null; diff --git a/app/Models/LoanDetail.php b/app/Models/LoanDetail.php new file mode 100644 index 00000000..bc570ca9 --- /dev/null +++ b/app/Models/LoanDetail.php @@ -0,0 +1,46 @@ + */ + use HasFactory, HasUuids; + + protected $fillable = [ + 'account_id', + 'annual_interest_rate', + 'loan_term_months', + 'start_date', + 'original_amount', + ]; + + protected function casts(): array + { + return [ + 'annual_interest_rate' => 'decimal:3', + 'loan_term_months' => 'integer', + 'start_date' => 'date', + 'original_amount' => 'integer', + ]; + } + + /** @return BelongsTo */ + public function account(): BelongsTo + { + return $this->belongsTo(Account::class); + } +} diff --git a/app/Models/RealEstateDetail.php b/app/Models/RealEstateDetail.php index e87fb6ed..a61d4397 100644 --- a/app/Models/RealEstateDetail.php +++ b/app/Models/RealEstateDetail.php @@ -8,10 +8,12 @@ use Illuminate\Database\Eloquent\Concerns\HasUuids; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Support\Carbon; /** * @property PropertyType $property_type * @property int|null $purchase_price + * @property Carbon|null $purchase_date * @property string|null $revaluation_percentage */ class RealEstateDetail extends Model diff --git a/app/Services/LoanAmortizationService.php b/app/Services/LoanAmortizationService.php new file mode 100644 index 00000000..09c636cb --- /dev/null +++ b/app/Services/LoanAmortizationService.php @@ -0,0 +1,216 @@ += $termMonths) { + return 0; + } + + if ($paymentsMade <= 0) { + return $principalCents; + } + + $principal = $principalCents / 100; + $monthlyRate = ($annualRate / 100) / 12; + + if ($monthlyRate === 0.0) { + $remaining = $principal - ($principal / $termMonths * $paymentsMade); + + return max(0, (int) round($remaining * 100)); + } + + $factorN = pow(1 + $monthlyRate, $termMonths); + $factorK = pow(1 + $monthlyRate, $paymentsMade); + $remaining = $principal * ($factorN - $factorK) / ($factorN - 1); + + return max(0, (int) round($remaining * 100)); + } + + /** + * Generate a monthly projection from the last known balance point forward. + * + * Finds the most recent account_balance entry and projects forward using + * the loan's current interest rate and remaining term. + * + * @param LoanDetail $loanDetail The loan's amortization parameters + * @param int $monthsAhead How many months into the future to project + * @return array Map of 'Y-m' => balance in cents + */ + public function generateProjection(LoanDetail $loanDetail, int $monthsAhead = 12): array + { + $lastBalance = AccountBalance::query() + ->where('account_id', $loanDetail->account_id) + ->orderBy('balance_date', 'desc') + ->first(); + + if ($lastBalance) { + return $this->projectFromBalance( + $lastBalance->balance, + $lastBalance->balance_date, + $loanDetail->annual_interest_rate, + $this->calculateRemainingMonths($loanDetail, $lastBalance->balance_date), + $monthsAhead, + ); + } + + return $this->projectFromOriginal($loanDetail, $monthsAhead); + } + + /** + * Project future balances starting from a known balance point. + * + * @param int $currentBalanceCents Current balance in cents + * @param Carbon $fromDate Date of the known balance + * @param float $annualRate Annual interest rate as percentage + * @param int $remainingMonths Remaining payments on the loan + * @param int $monthsAhead How many months to project forward + * @return array Map of 'Y-m' => balance in cents + */ + public function projectFromBalance( + int $currentBalanceCents, + Carbon $fromDate, + float $annualRate, + int $remainingMonths, + int $monthsAhead, + ): array { + $projection = []; + + $monthsToProject = min($monthsAhead, $remainingMonths); + + for ($i = 1; $i <= $monthsToProject; $i++) { + $date = $fromDate->copy()->addMonths($i); + $balance = $this->calculateRemainingBalance( + $currentBalanceCents, + $annualRate, + $remainingMonths, + $i, + ); + + $projection[$date->format('Y-m')] = $balance; + } + + return $projection; + } + + /** + * Project from the loan's original start date (no balance entries exist). + * + * @return array Map of 'Y-m' => balance in cents + */ + private function projectFromOriginal(LoanDetail $loanDetail, int $monthsAhead): array + { + $projection = []; + $now = Carbon::now(); + $start = $loanDetail->start_date->copy()->startOfMonth(); + + $monthsElapsed = (int) $start->diffInMonths($now); + $startMonth = max(0, $monthsElapsed); + + $monthsToProject = min($monthsAhead, $loanDetail->loan_term_months - $startMonth); + + for ($i = 1; $i <= $monthsToProject; $i++) { + $paymentNumber = $startMonth + $i; + if ($paymentNumber > $loanDetail->loan_term_months) { + break; + } + + $date = $now->copy()->addMonths($i); + $balance = $this->calculateRemainingBalance( + $loanDetail->original_amount, + $loanDetail->annual_interest_rate, + $loanDetail->loan_term_months, + $paymentNumber, + ); + + $projection[$date->format('Y-m')] = $balance; + } + + return $projection; + } + + /** + * Calculate how many months remain on the loan from a given date. + */ + public function calculateRemainingMonths(LoanDetail $loanDetail, Carbon $fromDate): int + { + $monthsElapsed = (int) $loanDetail->start_date->startOfMonth()->diffInMonths($fromDate->copy()->startOfMonth()); + $remaining = $loanDetail->loan_term_months - $monthsElapsed; + + return max(0, $remaining); + } + + /** + * Calculate the projected balance at a specific date for a loan. + * + * First checks for a real account_balance entry. If none exists, + * calculates from the loan's amortization schedule. + */ + public function getBalanceAtDate(LoanDetail $loanDetail, Carbon $date): int + { + $monthsElapsed = (int) $loanDetail->start_date->startOfMonth()->diffInMonths($date->copy()->startOfMonth()); + + if ($monthsElapsed >= $loanDetail->loan_term_months) { + return 0; + } + + if ($monthsElapsed <= 0) { + return $loanDetail->original_amount; + } + + return $this->calculateRemainingBalance( + $loanDetail->original_amount, + $loanDetail->annual_interest_rate, + $loanDetail->loan_term_months, + $monthsElapsed, + ); + } +} diff --git a/database/factories/AccountFactory.php b/database/factories/AccountFactory.php index 487296b0..9536e9a9 100644 --- a/database/factories/AccountFactory.php +++ b/database/factories/AccountFactory.php @@ -56,4 +56,11 @@ class AccountFactory extends Factory 'bank_id' => null, ]); } + + public function loan(): static + { + return $this->state(fn (array $attributes) => [ + 'type' => AccountType::Loan, + ]); + } } diff --git a/database/factories/LoanDetailFactory.php b/database/factories/LoanDetailFactory.php new file mode 100644 index 00000000..dfe18064 --- /dev/null +++ b/database/factories/LoanDetailFactory.php @@ -0,0 +1,30 @@ + + */ +class LoanDetailFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'account_id' => Account::factory()->state(['type' => AccountType::Loan]), + 'annual_interest_rate' => fake()->randomFloat(3, 1, 8), + 'loan_term_months' => fake()->randomElement([120, 180, 240, 300, 360]), + 'start_date' => fake()->dateTimeBetween('-10 years', 'now'), + 'original_amount' => fake()->numberBetween(5000000, 50000000), + ]; + } +} diff --git a/database/migrations/2026_03_25_132244_create_loan_details_table.php b/database/migrations/2026_03_25_132244_create_loan_details_table.php new file mode 100644 index 00000000..cfca8439 --- /dev/null +++ b/database/migrations/2026_03_25_132244_create_loan_details_table.php @@ -0,0 +1,32 @@ +char('id', 36)->primary(); + $table->foreignUuid('account_id')->unique()->constrained()->onDelete('cascade'); + $table->decimal('annual_interest_rate', 5, 3); + $table->integer('loan_term_months'); + $table->date('start_date'); + $table->bigInteger('original_amount'); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('loan_details'); + } +}; diff --git a/lang/es.json b/lang/es.json index 99250148..13d65d59 100644 --- a/lang/es.json +++ b/lang/es.json @@ -105,6 +105,7 @@ "Accounts need to be mapped before syncing can begin.": "Las cuentas deben mapearse antes de que pueda comenzar la sincronizaci\u00f3n.", "Accounts will become manual. All transactions and balances will be preserved.": "Las cuentas pasar\u00e1n a ser manuales. Todas las transacciones y saldos se conservar\u00e1n.", "Actions": "Acciones", + "Add": "Agregar", "Add Another Account": "Agregar Otra Cuenta", "Add Condition": "Agregar Condici\u00f3n", "Add Group": "Agregar Grupo", @@ -153,6 +154,8 @@ "Amount is required": "Se requiere un valor", "An unexpected error occurred during sync.": "Se produjo un error inesperado durante la sincronizaci\u00f3n.", "Annual": "Anual", + "Annual Interest Rate": "Tasa de Interés Anual", + "Annual Interest Rate (%)": "Tasa de Interés Anual (%)", "Annual Revaluation": "Revalorización anual", "Annual Revaluation (%)": "Revalorización anual (%)", "Annual percentage applied monthly. Use negative values for depreciation.": "Porcentaje anual aplicado mensualmente. Usa valores negativos para depreciación.", @@ -443,6 +446,7 @@ "Edit Budget": "Editar Presupuesto", "Edit Category": "Editar Categor\u00eda", "Edit Label": "Editar Etiqueta", + "Edit Loan Details": "Editar Detalles del Préstamo", "Edit Owed Amount": "Editar Monto Adeudado", "Edit Property Details": "Editar Detalles de Propiedad", "Edit Transaction": "Editar transacci\u00f3n", @@ -728,6 +732,10 @@ "Loading recovery codes": "Cargando c\u00f3digos de recuperaci\u00f3n", "Loading...": "Cargando...", "Loan": "Pr\u00e9stamo", + "Loan Details": "Detalles del Pr\u00e9stamo", + "Loan Start Date": "Fecha de Inicio del Pr\u00e9stamo", + "Loan Term": "Plazo del Pr\u00e9stamo", + "Loan Term (months)": "Plazo del Pr\u00e9stamo (meses)", "Loans": "Pr\u00e9stamos", "Lock encryption key": "Bloquear clave de encriptaci\u00f3n", "Log in": "Iniciar sesi\u00f3n", @@ -782,6 +790,7 @@ "Money going out of an account to pay for something (e.g., groceries, rent, subscriptions). Decreases your balance.": "Dinero que sale de una cuenta para pagar algo (ej., supermercado, alquiler, suscripciones). Disminuye tu balance.", "Month": "Mes", "Monthly": "Mensual", + "Monthly Payment": "Pago 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", @@ -839,6 +848,7 @@ "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 loan details yet. Add interest rate, term, and amount to track amortization.": "A\u00fan no hay detalles del pr\u00e9stamo. A\u00f1ade tipo de inter\u00e9s, plazo e importe para seguir la amortizaci\u00f3n.", "No market value data available": "No hay datos de valor de mercado disponibles", "No owed amount data available": "No hay datos de monto adeudado disponibles", "No owed amount records found.": "No se encontraron registros de monto adeudado.", @@ -866,10 +876,13 @@ "Open menu": "Abrir men\u00fa", "Open source": "C\u00f3digo abierto", "Optional. Set the current balance for this account.": "Opcional. Establece el balance actual de esta cuenta.", + "Optional. Provide loan details to automatically project your owed amount over time.": "Opcional. Proporciona los detalles del pr\u00e9stamo para proyectar autom\u00e1ticamente el monto adeudado a lo largo del tiempo.", "Or, return to": "O, regresar a", "Organize financial data with categories and custom labels": "Organiza datos financieros con categor\u00edas y etiquetas personalizadas", "Organize financial data with categories and\\n custom labels": "Organiza los datos financieros con categor\u00edas y etiquetas personalizadas", "Organize transactions with tags that make sense for your workflow.": "Organiza transacciones con etiquetas que tengan sentido para tu flujo de trabajo.", + "Original Amount": "Monto Original", + "Original Loan Amount": "Monto Original del Pr\u00e9stamo", "Other": "Otros", "Other Categories (": "Otras Categor\u00edas (", "Others": "Otros", @@ -950,6 +963,7 @@ "Property Details": "Detalles de Propiedad", "Property Type": "Tipo de Propiedad", "Property address": "Direcci\u00f3n de la propiedad", + "Projected": "Proyectado", "Protect your privacy with no tracking or third-party analytics.": "Protege tu privacidad sin rastreo ni an\u00e1lisis de terceros.", "Purchase Date": "Fecha de Compra", "Purchase Price": "Precio de Compra", @@ -1131,6 +1145,7 @@ "Standard": "Estandar", "Standard Plan required": "Plan Standard requerido", "Start Day": "D\u00eda de inicio", + "Start Date": "Fecha de Inicio", "Start Day of Month": "D\u00eda de inicio del mes", "Start My Financial Journey": "Comenzar Mi Viaje Financiero", "Start Your Financial Journey": "Comienza Tu Viaje Financiero", @@ -1595,6 +1610,7 @@ "category(ies) total.": "categor\u00eda(s) en total.", "contains": "contiene", "cyan": "cian", + "e.g. 360 for a 30-year mortgage": "ej. 360 para una hipoteca a 30 a\u00f1os", "e.g., Monthly Budget": "ej., Presupuesto Mensual", "e.g., Padel Budget": "ej., Presupuesto de P\u00e1del", "email@example.com": "email@example.com", @@ -1622,6 +1638,7 @@ "log in": "iniciar sesi\u00f3n", "market value": "valor de mercado", "market values": "valores de mercado", + "months": "meses", "m\u00b2": "m\u00b2", "neutral": "neutro", "of": "de", @@ -1665,6 +1682,7 @@ "vs last period": "vs per\u00edodo anterior", "vs start": "vs inicio", "will be updated or created.": "ser\u00e1 actualizado o creado.", + "years": "a\u00f1os", "yellow": "amarillo", "\u2014 $9/month": "\u2014 $9/mes", "\u2190 Back to home": "\u2190 Volver al inicio", diff --git a/resources/js/components/accounts/account-balance-chart.tsx b/resources/js/components/accounts/account-balance-chart.tsx index 43a1cd06..ee24e457 100644 --- a/resources/js/components/accounts/account-balance-chart.tsx +++ b/resources/js/components/accounts/account-balance-chart.tsx @@ -224,6 +224,7 @@ export interface BalanceDataPoint { value: number; invested_amount?: number | null; mortgage_balance?: number | null; + projected?: boolean; } interface AccountBalanceData { @@ -397,6 +398,7 @@ export function AccountBalanceChart({ currentInvestedAmount, currentMortgageBalance, hasMortgageData, + hasProjectedData, shortTrend, longTrend, } = useMemo(() => { @@ -407,13 +409,17 @@ export function AccountBalanceChart({ currentInvestedAmount: null as number | null, currentMortgageBalance: null as number | null, hasMortgageData: false, + hasProjectedData: false, shortTrend: null, longTrend: null, }; } const data = balanceData.data; - const current = data[data.length - 1]?.value ?? 0; + + // For trend calculations, use only non-projected data points + const historicalData = data.filter((d) => !d.projected); + const current = historicalData[historicalData.length - 1]?.value ?? 0; // Find the most recent non-null invested_amount let invested: number | null = null; @@ -447,14 +453,36 @@ export function AccountBalanceChart({ } } + const hasProjection = data.some((d) => d.projected); + + // Add projected_value field for chart rendering: + // - Historical points: value only, no projected_value + // - Last historical point: also gets projected_value to connect the lines + // - Projected points: both value and projected_value + let augmentedData = data; + if (hasProjection) { + const lastHistoricalIndex = data.findIndex((d) => d.projected) - 1; + augmentedData = data.map((d, i) => ({ + ...d, + projected_value: + d.projected || i === lastHistoricalIndex + ? d.value + : undefined, + })); + } + return { - chartData: data, + chartData: augmentedData, currentBalance: current, currentInvestedAmount: invested, currentMortgageBalance: mortgage, hasMortgageData: hasMortgage, - shortTrend: calculateTrend(data, 1), - longTrend: calculateTrend(data, data.length - 1), + hasProjectedData: hasProjection, + shortTrend: calculateTrend(historicalData, 1), + longTrend: calculateTrend( + historicalData, + historicalData.length - 1, + ), }; }, [balanceData]); @@ -520,6 +548,14 @@ export function AccountBalanceChart({ }, } : {}), + ...(hasProjectedData + ? { + projected_value: { + label: __('Projected'), + color: 'var(--color-chart-2)', + }, + } + : {}), }; const formatXAxisLabel = useMemo( @@ -855,6 +891,67 @@ export function AccountBalanceChart({ connectNulls /> + ) : hasProjectedData ? ( + + + + + + + + + + } + /> + + + ) : ( void; + errors?: Record; } const initialCustomBankData: CustomBankData = { @@ -91,12 +103,20 @@ const initialRealEstateData: RealEstateFormData = { revaluationPercentage: '', }; +const initialLoanData: LoanFormData = { + annualInterestRate: '', + loanTermMonths: '', + startDate: '', + originalAmount: 0, +}; + export function AccountForm({ initialValues, forceAccountType, hiddenAccountTypes = [], availableLoanAccounts = [], onChange, + errors = {}, }: AccountFormProps) { const [displayName, setDisplayName] = useState( initialValues?.displayName ?? '', @@ -115,12 +135,16 @@ export function AccountForm({ ); const [balance, setBalance] = useState(null); const [realEstateData, setRealEstateData] = useState( - initialRealEstateData, + initialValues?.realEstate ?? initialRealEstateData, + ); + const [loanData, setLoanData] = useState( + initialValues?.loan ?? initialLoanData, ); const showBalanceField = selectedType !== null && BALANCE_ACCOUNT_TYPES.includes(selectedType); const isRealEstate = selectedType === 'real_estate'; + const isLoan = selectedType === 'loan'; useEffect(() => { onChange({ @@ -131,6 +155,7 @@ export function AccountForm({ customBank: isCreatingCustomBank ? customBankData : null, balance: showBalanceField ? balance : null, realEstate: isRealEstate ? realEstateData : null, + loan: isLoan ? loanData : null, }); }, [ displayName, @@ -142,7 +167,9 @@ export function AccountForm({ balance, showBalanceField, isRealEstate, + isLoan, realEstateData, + loanData, onChange, ]); @@ -154,7 +181,10 @@ export function AccountForm({ setSelectedCurrency(initialValues.currencyCode); setIsCreatingCustomBank(false); setCustomBankData(initialCustomBankData); - setRealEstateData(initialRealEstateData); + setRealEstateData( + initialValues.realEstate ?? initialRealEstateData, + ); + setLoanData(initialValues.loan ?? initialLoanData); } }, [initialValues]); @@ -298,7 +328,7 @@ export function AccountForm({ - {showBalanceField && selectedCurrency && ( + {showBalanceField && selectedCurrency && !initialValues && (
)} + {isLoan && selectedCurrency && ( + <> +
+ + + setLoanData((prev) => ({ + ...prev, + annualInterestRate: e.target.value, + })) + } + placeholder="3.5" + min="0" + max="100" + step="0.001" + /> + +
+ +
+ + + setLoanData((prev) => ({ + ...prev, + loanTermMonths: e.target.value, + })) + } + placeholder="360" + min="1" + max="600" + /> + +

+ {__('e.g. 360 for a 30-year mortgage')} +

+
+ +
+ + + setLoanData((prev) => ({ + ...prev, + startDate: e.target.value, + })) + } + /> + +
+ +
+ +
+ + setLoanData((prev) => ({ + ...prev, + originalAmount: value, + })) + } + currencyCode={selectedCurrency} + /> +
+ +
+ +

+ {__( + 'Optional. Provide loan details to automatically project your owed amount over time.', + )} +

+ + )} + {isRealEstate && ( <>
diff --git a/resources/js/components/accounts/create-account-dialog.tsx b/resources/js/components/accounts/create-account-dialog.tsx index bd7f6ad3..95493853 100644 --- a/resources/js/components/accounts/create-account-dialog.tsx +++ b/resources/js/components/accounts/create-account-dialog.tsx @@ -61,6 +61,7 @@ export function CreateAccountDialog({ customBank: null, balance: null, realEstate: null, + loan: null, }); const handleFormChange = useCallback((data: AccountFormData) => { @@ -190,6 +191,21 @@ export function CreateAccountDialog({ .revaluationPercentage || null, } : {}), + ...(formDataRef.current.loan + ? { + annual_interest_rate: + formDataRef.current.loan.annualInterestRate || + null, + loan_term_months: + formDataRef.current.loan.loanTermMonths || + null, + loan_start_date: + formDataRef.current.loan.startDate || null, + original_amount: + formDataRef.current.loan.originalAmount || + null, + } + : {}), }, { onSuccess: () => { diff --git a/resources/js/components/accounts/edit-account-dialog.tsx b/resources/js/components/accounts/edit-account-dialog.tsx index a25a31f2..a31126ee 100644 --- a/resources/js/components/accounts/edit-account-dialog.tsx +++ b/resources/js/components/accounts/edit-account-dialog.tsx @@ -10,14 +10,24 @@ import { } from '@/components/ui/dialog'; import { decrypt, importKey } from '@/lib/crypto'; import { getStoredKey } from '@/lib/key-storage'; -import type { Account } from '@/types/account'; +import type { Account, LoanDetail, RealEstateDetail } from '@/types/account'; import { __ } from '@/utils/i18n'; import { router } from '@inertiajs/react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { AccountForm, AccountFormData } from './account-form'; +import { + AccountForm, + AccountFormData, + LoanFormData, + RealEstateFormData, +} from './account-form'; + +interface AccountWithDetails extends Account { + loan_detail?: LoanDetail | null; + real_estate_detail?: RealEstateDetail | null; +} interface EditAccountDialogProps { - account: Account; + account: AccountWithDetails; open: boolean; onOpenChange: (open: boolean) => void; onSuccess?: () => void; @@ -33,13 +43,16 @@ export function EditAccountDialog({ }: EditAccountDialogProps) { const [decryptedName, setDecryptedName] = useState(''); const [isSubmitting, setIsSubmitting] = useState(false); + const [errors, setErrors] = useState>({}); const formDataRef = useRef({ displayName: '', - bankId: account.bank?.id ?? null, + bankId: (account.bank?.id ?? null) as number | null, type: account.type, currencyCode: account.currency_code, customBank: null, + balance: null, realEstate: null, + loan: null, }); useEffect(() => { @@ -70,6 +83,36 @@ export function EditAccountDialog({ decryptName(); }, [open, account.name, account.name_iv, account.encrypted]); + const loanInitialData: LoanFormData | null = useMemo(() => { + const detail = account.loan_detail; + if (!detail) return null; + return { + annualInterestRate: detail.annual_interest_rate ?? '', + loanTermMonths: detail.loan_term_months?.toString() ?? '', + startDate: detail.start_date?.slice(0, 10) ?? '', + originalAmount: detail.original_amount ?? 0, + }; + }, [account.loan_detail]); + + const realEstateInitialData: RealEstateFormData | null = useMemo(() => { + const detail = account.real_estate_detail; + if (!detail) return null; + return { + propertyType: detail.property_type ?? null, + address: detail.address ?? '', + purchasePrice: detail.purchase_price ?? 0, + purchaseDate: detail.purchase_date ?? '', + areaValue: detail.area_value ?? '', + areaUnit: detail.area_unit ?? null, + linkedLoanAccountId: detail.linked_loan_account_id ?? null, + notes: detail.notes ?? '', + revaluationPercentage: + detail.revaluation_percentage != null + ? String(detail.revaluation_percentage) + : '', + }; + }, [account.real_estate_detail]); + const initialValues = useMemo( () => decryptedName && decryptedName !== '[Encrypted]' @@ -78,15 +121,30 @@ export function EditAccountDialog({ bank: account.bank, type: account.type, currencyCode: account.currency_code, + loan: loanInitialData, + realEstate: realEstateInitialData, } : undefined, - [decryptedName, account.bank, account.type, account.currency_code], + [ + decryptedName, + account.bank, + account.type, + account.currency_code, + loanInitialData, + realEstateInitialData, + ], ); const handleFormChange = useCallback((data: AccountFormData) => { formDataRef.current = data; }, []); + useEffect(() => { + if (open) { + setErrors({}); + } + }, [open]); + async function createBankAndGetId(): Promise { const customBank = formDataRef.current.customBank; if (!customBank) return null; @@ -172,10 +230,55 @@ export function EditAccountDialog({ ...(finalBankId ? { bank_id: finalBankId } : {}), type: type, currency_code: currencyCode, + ...(formDataRef.current.loan + ? { + annual_interest_rate: + formDataRef.current.loan.annualInterestRate || + null, + loan_term_months: + formDataRef.current.loan.loanTermMonths || + null, + loan_start_date: + formDataRef.current.loan.startDate || null, + original_amount: + formDataRef.current.loan.originalAmount ?? + null, + } + : {}), + ...(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 || + null, + linked_loan_account_id: + formDataRef.current.realEstate + .linkedLoanAccountId || null, + notes: + formDataRef.current.realEstate.notes || null, + revaluation_percentage: + formDataRef.current.realEstate + .revaluationPercentage || null, + } + : {}), }, { preserveScroll: true, onSuccess: () => { + setErrors({}); onOpenChange(false); if (redirectTo) { router.visit(redirectTo); @@ -183,6 +286,7 @@ export function EditAccountDialog({ onSuccess?.(); } }, + onError: (errors) => setErrors(errors), onFinish: () => { setIsSubmitting(false); }, @@ -213,6 +317,7 @@ export function EditAccountDialog({ ) : (
diff --git a/resources/js/pages/Accounts/Show.tsx b/resources/js/pages/Accounts/Show.tsx index 4fa68979..d2f768f7 100644 --- a/resources/js/pages/Accounts/Show.tsx +++ b/resources/js/pages/Accounts/Show.tsx @@ -1,4 +1,5 @@ import { index, show } from '@/actions/App/Http/Controllers/AccountController'; +import { update as updateLoanDetail } from '@/actions/App/Http/Controllers/LoanDetailController'; import { update as updateRealEstateDetail } from '@/actions/App/Http/Controllers/RealEstateDetailController'; import { AccountBalanceChart, @@ -13,6 +14,7 @@ import { UpdateBalanceDialog } from '@/components/accounts/update-balance-dialog import { BankLogo } from '@/components/bank-logo'; import { AmountTrendIndicator } from '@/components/dashboard/amount-trend-indicator'; import HeadingSmall from '@/components/heading-small'; +import InputError from '@/components/input-error'; import { MobileBackButton } from '@/components/mobile-back-button'; import { TransactionList } from '@/components/transactions/transaction-list'; import { AmountDisplay } from '@/components/ui/amount-display'; @@ -50,6 +52,7 @@ import { isTransactionalAccount, PROPERTY_TYPES, type AreaUnit, + type LoanDetail, type PropertyType, type RealEstateDetail, } from '@/types/account'; @@ -63,13 +66,14 @@ import { ChevronDown, Pencil } from 'lucide-react'; import { useCallback, useMemo, useState } from 'react'; import { Line, LineChart, ResponsiveContainer, Tooltip } from 'recharts'; -interface AccountWithRealEstate extends Account { +interface AccountWithDetails extends Account { real_estate_detail?: RealEstateDetail; available_loan_accounts?: Account[]; + loan_detail?: LoanDetail; } interface Props { - account: AccountWithRealEstate; + account: AccountWithDetails; categories: Category[]; accounts: Account[]; banks: Bank[]; @@ -92,6 +96,7 @@ export default function AccountShow({ const [balancesOpen, setBalancesOpen] = useState(false); const [chartRefreshKey, setChartRefreshKey] = useState(0); const [editingDetails, setEditingDetails] = useState(false); + const [editingLoanDetails, setEditingLoanDetails] = useState(false); const [chartComputedData, setChartComputedData] = useState(null); @@ -107,6 +112,7 @@ export default function AccountShow({ const isLoan = account.type === 'loan'; const isRealEstate = account.type === 'real_estate'; const realEstateDetail = account.real_estate_detail; + const loanDetail = account.loan_detail; const breadcrumbs: BreadcrumbItem[] = [ { @@ -259,6 +265,15 @@ export default function AccountShow({ /> )} + {isLoan && ( + + )} + {isTransactionalAccount(account) && ( void; @@ -969,3 +984,297 @@ function PropertyDetailsCard({ ); } + +function LoanDetailsCard({ + detail, + account, + isEditing, + onEditToggle, +}: { + detail: LoanDetail | null; + account: AccountWithDetails; + isEditing: boolean; + onEditToggle: (editing: boolean) => void; +}) { + const [isSubmitting, setIsSubmitting] = useState(false); + const [errors, setErrors] = useState>({}); + const [formData, setFormData] = useState({ + annual_interest_rate: detail?.annual_interest_rate ?? '', + loan_term_months: detail?.loan_term_months + ? String(detail.loan_term_months) + : '', + start_date: detail?.start_date?.slice(0, 10) ?? '', + original_amount: detail?.original_amount ?? 0, + }); + + function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + setIsSubmitting(true); + setErrors({}); + + router.patch( + updateLoanDetail.url(account.id), + { + annual_interest_rate: formData.annual_interest_rate, + loan_term_months: Number(formData.loan_term_months), + start_date: formData.start_date, + original_amount: formData.original_amount, + }, + { + preserveScroll: true, + onSuccess: () => onEditToggle(false), + onError: (errors) => setErrors(errors), + onFinish: () => setIsSubmitting(false), + }, + ); + } + + function handleCancel() { + setFormData({ + annual_interest_rate: detail?.annual_interest_rate ?? '', + loan_term_months: detail?.loan_term_months + ? String(detail.loan_term_months) + : '', + start_date: detail?.start_date?.slice(0, 10) ?? '', + original_amount: detail?.original_amount ?? 0, + }); + setErrors({}); + onEditToggle(false); + } + + if (isEditing) { + return ( + + + {__('Edit Loan Details')} + + +
+
+
+ + + setFormData((prev) => ({ + ...prev, + annual_interest_rate: + e.target.value, + })) + } + placeholder="3.500" + min="0" + max="99.999" + step="0.001" + /> + +
+ +
+ + + setFormData((prev) => ({ + ...prev, + loan_term_months: e.target.value, + })) + } + placeholder="360" + min="1" + max="600" + /> + +
+
+ +
+
+ + + setFormData((prev) => ({ + ...prev, + start_date: e.target.value, + })) + } + /> + +
+ +
+ + + setFormData((prev) => ({ + ...prev, + original_amount: value, + })) + } + currencyCode={account.currency_code} + /> + +
+
+ +
+ + +
+
+
+
+ ); + } + + if (!detail) { + return ( + + + {__('Loan Details')} + + + +

+ {__( + 'No loan details yet. Add interest rate, term, and amount to track amortization.', + )} +

+
+
+ ); + } + + return ( + + + {__('Loan Details')} + + + +
+
+
+ {__('Annual Interest Rate')} +
+
+ {detail.annual_interest_rate}% +
+
+ +
+
+ {__('Loan Term')} +
+
+ {detail.loan_term_months} {__('months')} + {detail.loan_term_months >= 12 && ( + + ({Math.floor(detail.loan_term_months / 12)}{' '} + {__('years')}) + + )} +
+
+ +
+
+ {__('Start Date')} +
+
+ {formatDateMedium(detail.start_date)} +
+
+ +
+
+ {__('Original Amount')} +
+
+ +
+
+ + {detail.monthly_payment !== null && ( +
+
+ {__('Monthly Payment')} +
+
+ +
+
+ )} + + {detail.remaining_months !== null && ( +
+
+ {__('Remaining')} +
+
+ {detail.remaining_months} {__('months')} + {detail.remaining_months >= 12 && ( + + ( + {Math.floor( + detail.remaining_months / 12, + )}{' '} + {__('years')}) + + )} +
+
+ )} +
+
+
+ ); +} diff --git a/resources/js/types/account.ts b/resources/js/types/account.ts index 878ab229..62634287 100644 --- a/resources/js/types/account.ts +++ b/resources/js/types/account.ts @@ -101,6 +101,16 @@ export interface RealEstateDetail { current_loan_balance: number | null; } +export interface LoanDetail { + id: UUID; + annual_interest_rate: string; + loan_term_months: number; + start_date: string; + original_amount: number; + monthly_payment: number | null; + remaining_months: number | null; +} + export function formatPropertyType(type: PropertyType): string { const typeMap: Record = { residential: __('Residential'), @@ -138,6 +148,7 @@ export function formatAccountType(type: AccountType): string { const NON_TRANSACTIONAL_ACCOUNT_TYPES: AccountType[] = [ 'investment', + 'loan', 'real_estate', 'retirement', ]; diff --git a/routes/console.php b/routes/console.php index a3e3fc37..3afc170e 100644 --- a/routes/console.php +++ b/routes/console.php @@ -8,3 +8,4 @@ Schedule::command('budgets:generate-periods')->daily(); Schedule::command('banking:sync')->everySixHours(); Schedule::command('banks:check-logos')->weekly(); Schedule::command('real-estate:apply-revaluation')->monthlyOn(1, '00:00'); +Schedule::command('loans:generate-balances')->monthlyOn(1, '00:00'); diff --git a/routes/web.php b/routes/web.php index 90a97128..efb36618 100644 --- a/routes/web.php +++ b/routes/web.php @@ -4,6 +4,7 @@ use App\Http\Controllers\AccountController; use App\Http\Controllers\BudgetController; use App\Http\Controllers\CashflowController; use App\Http\Controllers\DashboardController; +use App\Http\Controllers\LoanDetailController; use App\Http\Controllers\OnboardingController; use App\Http\Controllers\OpenBanking\AccountMappingController; use App\Http\Controllers\OpenBanking\AuthorizationController; @@ -101,6 +102,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::patch('accounts/{account}/loan-detail', [LoanDetailController::class, 'update'])->name('accounts.loan-detail.update'); Route::get('transactions', [TransactionController::class, 'index'])->name('transactions.index'); Route::get('transactions/categorize', [TransactionController::class, 'categorize'])->name('transactions.categorize'); diff --git a/tests/Feature/LoanTest.php b/tests/Feature/LoanTest.php new file mode 100644 index 00000000..42eafd82 --- /dev/null +++ b/tests/Feature/LoanTest.php @@ -0,0 +1,823 @@ +user = User::factory()->onboarded()->create(); + $this->bank = Bank::factory()->create(); + $this->service = app(LoanAmortizationService::class); +}); + +// ------------------------------------------------------------------- +// LoanAmortizationService — methods requiring Eloquent models (DB) +// ------------------------------------------------------------------- + +it('calculates remaining months correctly', function () { + $account = Account::factory()->loan()->create(['user_id' => $this->user->id, 'bank_id' => $this->bank->id]); + $loanDetail = LoanDetail::factory()->create([ + 'account_id' => $account->id, + 'start_date' => now()->subMonths(24), + 'loan_term_months' => 360, + ]); + + $remaining = $this->service->calculateRemainingMonths($loanDetail, now()); + + expect($remaining)->toBe(336); +}); + +it('returns zero remaining months when loan is past term', function () { + $account = Account::factory()->loan()->create(['user_id' => $this->user->id, 'bank_id' => $this->bank->id]); + $loanDetail = LoanDetail::factory()->create([ + 'account_id' => $account->id, + 'start_date' => now()->subMonths(400), + 'loan_term_months' => 360, + ]); + + $remaining = $this->service->calculateRemainingMonths($loanDetail, now()); + + expect($remaining)->toBe(0); +}); + +it('calculates balance at a specific date from loan details', function () { + $account = Account::factory()->loan()->create(['user_id' => $this->user->id, 'bank_id' => $this->bank->id]); + $loanDetail = LoanDetail::factory()->create([ + 'account_id' => $account->id, + 'start_date' => now()->subMonths(12), + 'loan_term_months' => 360, + 'annual_interest_rate' => 3.5, + 'original_amount' => 20000000, + ]); + + $balance = $this->service->getBalanceAtDate($loanDetail, now()); + + // After 12 months of a $200k loan at 3.5%, should still owe ~$195k-$198k + expect($balance)->toBeGreaterThan(19500000) + ->toBeLessThan(19800000); +}); + +it('returns original amount for balance before loan start', function () { + $account = Account::factory()->loan()->create(['user_id' => $this->user->id, 'bank_id' => $this->bank->id]); + $loanDetail = LoanDetail::factory()->create([ + 'account_id' => $account->id, + 'start_date' => now()->addMonths(6), + 'loan_term_months' => 360, + 'annual_interest_rate' => 3.5, + 'original_amount' => 20000000, + ]); + + $balance = $this->service->getBalanceAtDate($loanDetail, now()); + + expect($balance)->toBe(20000000); +}); + +it('returns zero for balance after loan term ends', function () { + $account = Account::factory()->loan()->create(['user_id' => $this->user->id, 'bank_id' => $this->bank->id]); + $loanDetail = LoanDetail::factory()->create([ + 'account_id' => $account->id, + 'start_date' => now()->subMonths(400), + 'loan_term_months' => 360, + 'annual_interest_rate' => 3.5, + 'original_amount' => 20000000, + ]); + + $balance = $this->service->getBalanceAtDate($loanDetail, now()); + + expect($balance)->toBe(0); +}); + +it('generates projection from last account balance entry', function () { + $account = Account::factory()->loan()->create(['user_id' => $this->user->id, 'bank_id' => $this->bank->id]); + $loanDetail = LoanDetail::factory()->create([ + 'account_id' => $account->id, + 'start_date' => now()->subMonths(12), + 'loan_term_months' => 360, + 'annual_interest_rate' => 3.5, + 'original_amount' => 20000000, + ]); + + AccountBalance::create([ + 'account_id' => $account->id, + 'balance_date' => now()->subMonth()->startOfMonth()->toDateString(), + 'balance' => 19700000, + ]); + + $projection = $this->service->generateProjection($loanDetail, 6); + + expect($projection)->not->toBeEmpty() + ->and(count($projection))->toBeLessThanOrEqual(6); + + // Each projected month should be decreasing + $values = array_values($projection); + for ($i = 1; $i < count($values); $i++) { + expect($values[$i])->toBeLessThan($values[$i - 1]); + } +}); + +// ------------------------------------------------------------------- +// Creating loan accounts via Settings\AccountController@store +// ------------------------------------------------------------------- + +it('can create a loan account with loan details', function () { + actingAs($this->user); + + $data = [ + 'name' => 'Home Mortgage', + 'bank_id' => $this->bank->id, + 'currency_code' => 'USD', + 'type' => AccountType::Loan->value, + 'annual_interest_rate' => 3.5, + 'loan_term_months' => 360, + 'loan_start_date' => '2024-01-15', + 'original_amount' => 20000000, + ]; + + $response = $this->post(route('accounts.store'), $data); + + $response->assertRedirect(); + + assertDatabaseHas('accounts', [ + 'user_id' => $this->user->id, + 'name' => 'Home Mortgage', + 'type' => AccountType::Loan->value, + ]); + + $account = Account::query() + ->where('user_id', $this->user->id) + ->where('type', AccountType::Loan->value) + ->first(); + + assertDatabaseHas('loan_details', [ + 'account_id' => $account->id, + 'annual_interest_rate' => 3.500, + 'loan_term_months' => 360, + 'start_date' => '2024-01-15', + 'original_amount' => 20000000, + ]); +}); + +it('can create a loan account without loan details', function () { + actingAs($this->user); + + $data = [ + 'name' => 'Simple Loan', + 'bank_id' => $this->bank->id, + 'currency_code' => 'USD', + 'type' => AccountType::Loan->value, + ]; + + $response = $this->post(route('accounts.store'), $data); + + $response->assertRedirect(); + + $account = Account::query() + ->where('user_id', $this->user->id) + ->where('name', 'Simple Loan') + ->first(); + + expect($account)->not->toBeNull(); + + assertDatabaseMissing('loan_details', [ + 'account_id' => $account->id, + ]); +}); + +it('validates loan fields when creating a loan account', function () { + actingAs($this->user); + + $data = [ + 'name' => 'Bad Loan', + 'bank_id' => $this->bank->id, + 'currency_code' => 'USD', + 'type' => AccountType::Loan->value, + 'annual_interest_rate' => 150, // over 100 + 'loan_term_months' => 700, // over 600 + 'original_amount' => -100, // negative + ]; + + $response = $this->post(route('accounts.store'), $data); + + $response->assertSessionHasErrors([ + 'annual_interest_rate', + 'loan_term_months', + 'original_amount', + ]); +}); + +it('does not apply loan validation rules for non-loan 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(); + $response->assertSessionDoesntHaveErrors(); +}); + +// ------------------------------------------------------------------- +// Updating loan accounts via Settings\AccountController@update +// ------------------------------------------------------------------- + +it('can update a loan account with loan details', function () { + actingAs($this->user); + + $account = Account::factory()->loan()->create([ + 'user_id' => $this->user->id, + 'bank_id' => $this->bank->id, + ]); + + $data = [ + 'name' => 'Updated Mortgage', + 'bank_id' => $this->bank->id, + 'currency_code' => 'USD', + 'type' => AccountType::Loan->value, + 'annual_interest_rate' => 4.25, + 'loan_term_months' => 240, + 'loan_start_date' => '2023-06-01', + 'original_amount' => 15000000, + ]; + + $response = $this->patch(route('accounts.update', $account), $data); + + $response->assertRedirect(route('accounts.index')); + + assertDatabaseHas('loan_details', [ + 'account_id' => $account->id, + 'annual_interest_rate' => 4.250, + 'loan_term_months' => 240, + 'start_date' => '2023-06-01', + 'original_amount' => 15000000, + ]); +}); + +it('can update existing loan detail via account update', function () { + actingAs($this->user); + + $account = Account::factory()->loan()->create([ + 'user_id' => $this->user->id, + 'bank_id' => $this->bank->id, + ]); + + LoanDetail::factory()->create([ + 'account_id' => $account->id, + 'annual_interest_rate' => 3.5, + 'loan_term_months' => 360, + 'original_amount' => 20000000, + ]); + + $data = [ + 'name' => $account->name, + 'bank_id' => $this->bank->id, + 'currency_code' => $account->currency_code, + 'type' => AccountType::Loan->value, + 'annual_interest_rate' => 5.0, + 'loan_term_months' => 360, + 'original_amount' => 20000000, + ]; + + $response = $this->patch(route('accounts.update', $account), $data); + + $response->assertRedirect(route('accounts.index')); + + assertDatabaseHas('loan_details', [ + 'account_id' => $account->id, + 'annual_interest_rate' => 5.000, + ]); + + // Should only have one loan detail record + expect(LoanDetail::where('account_id', $account->id)->count())->toBe(1); +}); + +it('creates loan detail when updating a loan account with all required fields and no existing loan detail', function () { + actingAs($this->user); + + $account = Account::factory()->loan()->create([ + 'user_id' => $this->user->id, + 'bank_id' => $this->bank->id, + ]); + + // No loan detail exists — simulate the edit dialog filling in all fields + $data = [ + 'name' => $account->name, + 'bank_id' => $this->bank->id, + 'currency_code' => $account->currency_code, + 'type' => AccountType::Loan->value, + 'annual_interest_rate' => '3.5', + 'loan_term_months' => '360', + 'loan_start_date' => '2026-01-01', + 'original_amount' => 200000, + ]; + + $response = $this->patch(route('accounts.update', $account), $data); + + $response->assertRedirect(route('accounts.index')); + + assertDatabaseHas('loan_details', [ + 'account_id' => $account->id, + 'annual_interest_rate' => 3.500, + 'loan_term_months' => 360, + 'start_date' => '2026-01-01', + 'original_amount' => 200000, + ]); +}); + +it('does not crash when updating a loan account with partial loan data and no existing loan detail', function () { + actingAs($this->user); + + $account = Account::factory()->loan()->create([ + 'user_id' => $this->user->id, + 'bank_id' => $this->bank->id, + ]); + + $data = [ + 'name' => $account->name, + 'bank_id' => $this->bank->id, + 'currency_code' => $account->currency_code, + 'type' => AccountType::Loan->value, + 'loan_start_date' => '2011-09-23', + ]; + + $response = $this->patch(route('accounts.update', $account), $data); + + $response->assertRedirect(route('accounts.index')); + $response->assertSessionHasErrors(['annual_interest_rate', 'loan_term_months', 'original_amount']); + + // Should not have created a loan detail with incomplete data + assertDatabaseMissing('loan_details', [ + 'account_id' => $account->id, + ]); +}); + +it('can partially update existing loan detail via account update', function () { + actingAs($this->user); + + $account = Account::factory()->loan()->create([ + 'user_id' => $this->user->id, + 'bank_id' => $this->bank->id, + ]); + + LoanDetail::factory()->create([ + 'account_id' => $account->id, + 'annual_interest_rate' => 3.5, + 'loan_term_months' => 360, + 'start_date' => '2020-01-01', + 'original_amount' => 20000000, + ]); + + $data = [ + 'name' => $account->name, + 'bank_id' => $this->bank->id, + 'currency_code' => $account->currency_code, + 'type' => AccountType::Loan->value, + 'loan_start_date' => '2011-09-23', + ]; + + $response = $this->patch(route('accounts.update', $account), $data); + + $response->assertRedirect(route('accounts.index')); + + assertDatabaseHas('loan_details', [ + 'account_id' => $account->id, + 'start_date' => '2011-09-23', + 'annual_interest_rate' => 3.500, + 'loan_term_months' => 360, + 'original_amount' => 20000000, + ]); +}); + +// ------------------------------------------------------------------- +// LoanDetailController — updating loan details from show page +// ------------------------------------------------------------------- + +it('can update loan detail via loan detail controller', function () { + actingAs($this->user); + + $account = Account::factory()->loan()->create([ + 'user_id' => $this->user->id, + 'bank_id' => $this->bank->id, + ]); + + LoanDetail::factory()->create([ + 'account_id' => $account->id, + 'annual_interest_rate' => 3.5, + 'loan_term_months' => 360, + ]); + + $response = $this->patch(route('accounts.loan-detail.update', $account), [ + 'annual_interest_rate' => 4.75, + 'loan_term_months' => 300, + ]); + + $response->assertRedirect(route('accounts.show', $account)); + + assertDatabaseHas('loan_details', [ + 'account_id' => $account->id, + 'annual_interest_rate' => 4.750, + 'loan_term_months' => 300, + ]); +}); + +it('can create loan detail via loan detail controller when none exists', function () { + actingAs($this->user); + + $account = Account::factory()->loan()->create([ + 'user_id' => $this->user->id, + 'bank_id' => $this->bank->id, + ]); + + $response = $this->patch(route('accounts.loan-detail.update', $account), [ + 'annual_interest_rate' => 3.5, + 'loan_term_months' => 360, + 'start_date' => '2024-01-01', + 'original_amount' => 20000000, + ]); + + $response->assertRedirect(route('accounts.show', $account)); + + assertDatabaseHas('loan_details', [ + 'account_id' => $account->id, + 'annual_interest_rate' => 3.500, + 'loan_term_months' => 360, + 'start_date' => '2024-01-01', + 'original_amount' => 20000000, + ]); +}); + +it('does not crash when creating loan detail via loan detail controller with partial data', function () { + actingAs($this->user); + + $account = Account::factory()->loan()->create([ + 'user_id' => $this->user->id, + 'bank_id' => $this->bank->id, + ]); + + $response = $this->patch(route('accounts.loan-detail.update', $account), [ + 'start_date' => '2024-01-01', + ]); + + $response->assertRedirect(route('accounts.show', $account)); + $response->assertSessionHasErrors(['annual_interest_rate', 'loan_term_months', 'original_amount']); + + assertDatabaseMissing('loan_details', [ + 'account_id' => $account->id, + ]); +}); + +it('validates loan detail fields on update', function () { + actingAs($this->user); + + $account = Account::factory()->loan()->create([ + 'user_id' => $this->user->id, + 'bank_id' => $this->bank->id, + ]); + + LoanDetail::factory()->create(['account_id' => $account->id]); + + $response = $this->patch(route('accounts.loan-detail.update', $account), [ + 'annual_interest_rate' => 200, // over 100 + 'loan_term_months' => -5, // negative + ]); + + $response->assertSessionHasErrors(['annual_interest_rate', 'loan_term_months']); +}); + +it('prevents updating another users loan detail', function () { + actingAs($this->user); + + $otherUser = User::factory()->create(); + $otherAccount = Account::factory()->loan()->create([ + 'user_id' => $otherUser->id, + 'bank_id' => $this->bank->id, + ]); + + LoanDetail::factory()->create(['account_id' => $otherAccount->id]); + + $response = $this->patch(route('accounts.loan-detail.update', $otherAccount), [ + 'annual_interest_rate' => 1.0, + ]); + + $response->assertForbidden(); +}); + +// ------------------------------------------------------------------- +// Account show page loads loan data +// ------------------------------------------------------------------- + +it('loads loan detail on account show page', function () { + $this->withoutVite(); + actingAs($this->user); + + $account = Account::factory()->loan()->create([ + 'user_id' => $this->user->id, + 'bank_id' => $this->bank->id, + ]); + + LoanDetail::factory()->create([ + 'account_id' => $account->id, + 'annual_interest_rate' => 3.5, + 'loan_term_months' => 360, + 'start_date' => now()->subMonths(12), + 'original_amount' => 20000000, + ]); + + $response = $this->get(route('accounts.show', $account)); + + $response->assertOk() + ->assertInertia(fn ($page) => $page + ->component('Accounts/Show') + ->has('account.loan_detail') + ->where('account.loan_detail.annual_interest_rate', '3.500') + ->where('account.loan_detail.loan_term_months', 360) + ->where('account.loan_detail.original_amount', 20000000) + ->has('account.loan_detail.monthly_payment') + ->has('account.loan_detail.remaining_months') + ); +}); + +it('does not load loan detail for non-loan accounts', function () { + $this->withoutVite(); + actingAs($this->user); + + $account = Account::factory()->create([ + 'user_id' => $this->user->id, + 'bank_id' => $this->bank->id, + 'type' => AccountType::Checking, + ]); + + $response = $this->get(route('accounts.show', $account)); + + $response->assertOk() + ->assertInertia(fn ($page) => $page + ->component('Accounts/Show') + ->missing('account.loan_detail') + ); +}); + +it('calculates monthly payment from current balance when balance entries exist', function () { + $this->withoutVite(); + actingAs($this->user); + + $account = Account::factory()->loan()->create([ + 'user_id' => $this->user->id, + 'bank_id' => $this->bank->id, + ]); + + $loanDetail = LoanDetail::factory()->create([ + 'account_id' => $account->id, + 'annual_interest_rate' => 4.110, + 'loan_term_months' => 333, + 'start_date' => now()->subMonths(138), + 'original_amount' => 7991346, + ]); + + // Simulate an early payment reducing the balance below the amortization schedule + AccountBalance::create([ + 'account_id' => $account->id, + 'balance' => 4489670, + 'balance_date' => now()->subDays(5), + ]); + + $service = app(LoanAmortizationService::class); + $remainingMonths = $service->calculateRemainingMonths($loanDetail, now()); + + $expectedPayment = $service->calculateMonthlyPayment( + 4489670, + 4.110, + $remainingMonths, + ); + + $originalPayment = $service->calculateMonthlyPayment( + 7991346, + 4.110, + 333, + ); + + // The payment based on current balance should be less than original + expect($expectedPayment)->toBeLessThan($originalPayment); + + $response = $this->get(route('accounts.show', $account)); + + $response->assertOk() + ->assertInertia(fn ($page) => $page + ->component('Accounts/Show') + ->where('account.loan_detail.monthly_payment', $expectedPayment) + ); +}); + +it('shows loan account without loan detail gracefully', function () { + $this->withoutVite(); + actingAs($this->user); + + $account = Account::factory()->loan()->create([ + 'user_id' => $this->user->id, + 'bank_id' => $this->bank->id, + ]); + + $response = $this->get(route('accounts.show', $account)); + + $response->assertOk() + ->assertInertia(fn ($page) => $page + ->component('Accounts/Show') + ->missing('account.loan_detail') + ); +}); + +// ------------------------------------------------------------------- +// GenerateMonthlyLoanBalances command +// ------------------------------------------------------------------- + +it('generates monthly balance entries for loan accounts', function () { + $account = Account::factory()->loan()->create([ + 'user_id' => $this->user->id, + 'bank_id' => $this->bank->id, + ]); + + LoanDetail::factory()->create([ + 'account_id' => $account->id, + 'start_date' => now()->subMonths(12), + 'loan_term_months' => 360, + 'annual_interest_rate' => 3.5, + 'original_amount' => 20000000, + ]); + + artisan('loans:generate-balances')->assertSuccessful(); + + assertDatabaseHas('account_balances', [ + 'account_id' => $account->id, + 'balance_date' => now()->startOfMonth()->toDateString(), + ]); +}); + +it('skips loan accounts that already have a balance for current month', function () { + $account = Account::factory()->loan()->create([ + 'user_id' => $this->user->id, + 'bank_id' => $this->bank->id, + ]); + + LoanDetail::factory()->create([ + 'account_id' => $account->id, + 'start_date' => now()->subMonths(12), + 'loan_term_months' => 360, + 'annual_interest_rate' => 3.5, + 'original_amount' => 20000000, + ]); + + AccountBalance::create([ + 'account_id' => $account->id, + 'balance_date' => now()->startOfMonth()->toDateString(), + 'balance' => 19500000, + ]); + + artisan('loans:generate-balances')->assertSuccessful(); + + // Should still have only one balance entry, not duplicated + expect(AccountBalance::where('account_id', $account->id)->count())->toBe(1); + + // Original balance should be unchanged + assertDatabaseHas('account_balances', [ + 'account_id' => $account->id, + 'balance' => 19500000, + ]); +}); + +it('skips loan accounts without loan details', function () { + $account = Account::factory()->loan()->create([ + 'user_id' => $this->user->id, + 'bank_id' => $this->bank->id, + ]); + + artisan('loans:generate-balances')->assertSuccessful(); + + assertDatabaseMissing('account_balances', [ + 'account_id' => $account->id, + ]); +}); + +// ------------------------------------------------------------------- +// Projection API endpoint +// ------------------------------------------------------------------- + +it('returns projected data for loan accounts in balance evolution API', function () { + actingAs($this->user); + + $account = Account::factory()->loan()->create([ + 'user_id' => $this->user->id, + 'bank_id' => $this->bank->id, + ]); + + LoanDetail::factory()->create([ + 'account_id' => $account->id, + 'start_date' => now()->subMonths(12), + 'loan_term_months' => 360, + 'annual_interest_rate' => 3.5, + 'original_amount' => 20000000, + ]); + + AccountBalance::create([ + 'account_id' => $account->id, + 'balance_date' => now()->subMonth()->startOfMonth()->toDateString(), + 'balance' => 19700000, + ]); + + $from = now()->subMonths(6)->toDateString(); + $to = now()->toDateString(); + + $response = $this->getJson("/api/dashboard/account/{$account->id}/balance-evolution?from={$from}&to={$to}"); + + $response->assertSuccessful(); + + $data = $response->json('data'); + $projectedPoints = collect($data)->where('projected', true); + + expect($projectedPoints)->not->toBeEmpty(); + + // All projected points should have a value + $projectedPoints->each(function ($point) { + expect($point['value'])->toBeInt(); + expect($point['projected'])->toBeTrue(); + }); +}); + +it('does not return projected data for non-loan accounts', function () { + actingAs($this->user); + + $account = Account::factory()->create([ + 'user_id' => $this->user->id, + 'bank_id' => $this->bank->id, + 'type' => AccountType::Checking, + ]); + + AccountBalance::create([ + 'account_id' => $account->id, + 'balance_date' => now()->subMonth()->startOfMonth()->toDateString(), + 'balance' => 500000, + ]); + + $from = now()->subMonths(6)->toDateString(); + $to = now()->toDateString(); + + $response = $this->getJson("/api/dashboard/account/{$account->id}/balance-evolution?from={$from}&to={$to}"); + + $response->assertSuccessful(); + + $data = $response->json('data'); + $projectedPoints = collect($data)->where('projected', true); + + expect($projectedPoints)->toBeEmpty(); +}); + +// ------------------------------------------------------------------- +// Model relationships +// ------------------------------------------------------------------- + +it('has a one-to-one relationship between account and loan detail', function () { + $account = Account::factory()->loan()->create([ + 'user_id' => $this->user->id, + 'bank_id' => $this->bank->id, + ]); + + $detail = LoanDetail::factory()->create([ + 'account_id' => $account->id, + 'annual_interest_rate' => 4.5, + ]); + + expect($account->fresh()->loanDetail)->not->toBeNull(); + expect($account->fresh()->loanDetail->id)->toBe($detail->id); + expect($detail->fresh()->account->id)->toBe($account->id); +}); + +it('preserves loan detail when account is soft deleted', function () { + actingAs($this->user); + + $account = Account::factory()->loan()->create([ + 'user_id' => $this->user->id, + 'bank_id' => $this->bank->id, + ]); + + $detail = LoanDetail::factory()->create([ + 'account_id' => $account->id, + ]); + + $this->delete(route('accounts.destroy', $account)); + + expect(Account::find($account->id))->toBeNull(); + expect(Account::withTrashed()->find($account->id))->not->toBeNull(); + + assertDatabaseHas('loan_details', ['id' => $detail->id]); +}); diff --git a/tests/Feature/RealEstateTest.php b/tests/Feature/RealEstateTest.php index 7d7c4f90..694008c6 100644 --- a/tests/Feature/RealEstateTest.php +++ b/tests/Feature/RealEstateTest.php @@ -745,3 +745,106 @@ it('can clear revaluation percentage by setting null', function () { 'revaluation_percentage' => null, ]); }); + +// ------------------------------------------------------------------- +// Show page returns revaluation_percentage and purchase_date correctly +// ------------------------------------------------------------------- + +it('loads revaluation_percentage and purchase_date on account show page', function () { + $this->withoutVite(); + actingAs($this->user); + + $account = Account::factory()->realEstate()->create([ + 'user_id' => $this->user->id, + ]); + + RealEstateDetail::factory()->create([ + 'account_id' => $account->id, + 'property_type' => PropertyType::Residential, + 'purchase_date' => '2023-06-15', + 'revaluation_percentage' => 5.25, + ]); + + $response = $this->get(route('accounts.show', $account)); + + $response->assertOk() + ->assertInertia(fn ($page) => $page + ->component('Accounts/Show') + ->has('account.real_estate_detail') + ->where('account.real_estate_detail.revaluation_percentage', '5.25') + ->where('account.real_estate_detail.purchase_date', '2023-06-15') + ); +}); + +// ------------------------------------------------------------------- +// Updating real estate accounts via Settings\AccountController@update +// ------------------------------------------------------------------- + +it('can update real estate details via account update endpoint', function () { + actingAs($this->user); + + $account = Account::factory()->realEstate()->create([ + 'user_id' => $this->user->id, + ]); + + RealEstateDetail::factory()->create([ + 'account_id' => $account->id, + 'property_type' => PropertyType::Residential, + 'address' => 'Old Address', + 'purchase_date' => '2020-01-01', + 'revaluation_percentage' => 2.00, + ]); + + $response = $this->patch(route('accounts.update', $account), [ + 'name' => 'Updated Property', + 'currency_code' => 'EUR', + 'type' => AccountType::RealEstate->value, + 'property_type' => PropertyType::Commercial->value, + 'address' => 'New Address', + 'purchase_price' => 30000000, + 'purchase_date' => '2023-06-15', + 'revaluation_percentage' => 4.50, + ]); + + $response->assertRedirect(route('accounts.index')); + + assertDatabaseHas('accounts', [ + 'id' => $account->id, + 'name' => 'Updated Property', + ]); + + assertDatabaseHas('real_estate_details', [ + 'account_id' => $account->id, + 'property_type' => PropertyType::Commercial->value, + 'address' => 'New Address', + 'purchase_price' => 30000000, + 'revaluation_percentage' => '4.50', + ]); +}); + +it('creates real estate detail via account update if it does not exist', function () { + actingAs($this->user); + + $account = Account::factory()->realEstate()->create([ + 'user_id' => $this->user->id, + ]); + + // No RealEstateDetail created yet + + $response = $this->patch(route('accounts.update', $account), [ + 'name' => 'New Property', + 'currency_code' => 'USD', + 'type' => AccountType::RealEstate->value, + 'property_type' => PropertyType::Land->value, + 'purchase_date' => '2024-01-01', + 'revaluation_percentage' => 1.50, + ]); + + $response->assertRedirect(route('accounts.index')); + + assertDatabaseHas('real_estate_details', [ + 'account_id' => $account->id, + 'property_type' => PropertyType::Land->value, + 'revaluation_percentage' => '1.50', + ]); +}); diff --git a/tests/Unit/Services/LoanAmortizationServiceTest.php b/tests/Unit/Services/LoanAmortizationServiceTest.php new file mode 100644 index 00000000..153df079 --- /dev/null +++ b/tests/Unit/Services/LoanAmortizationServiceTest.php @@ -0,0 +1,97 @@ +service = new LoanAmortizationService; +}); + +it('calculates monthly payment for a standard loan', function () { + // $200,000 loan at 3.5% for 30 years (360 months) + $payment = $this->service->calculateMonthlyPayment(20000000, 3.5, 360); + + // Expected ~$898.09/month = ~89809 cents + expect($payment)->toBeGreaterThan(89500) + ->toBeLessThan(90100); +}); + +it('calculates monthly payment for a zero interest loan', function () { + // $120,000 loan at 0% for 10 years (120 months) = $1000/month + $payment = $this->service->calculateMonthlyPayment(12000000, 0, 120); + + expect($payment)->toBe(10000_0); +}); + +it('returns zero payment for zero term', function () { + $payment = $this->service->calculateMonthlyPayment(20000000, 3.5, 0); + + expect($payment)->toBe(0); +}); + +it('calculates remaining balance after payments', function () { + // $200,000 at 3.5% for 360 months, after 12 payments + $remaining = $this->service->calculateRemainingBalance(20000000, 3.5, 360, 12); + + // After 1 year, should still owe most of the principal + expect($remaining)->toBeGreaterThan(19500000) + ->toBeLessThan(19800000); +}); + +it('returns zero balance when all payments made', function () { + $remaining = $this->service->calculateRemainingBalance(20000000, 3.5, 360, 360); + + expect($remaining)->toBe(0); +}); + +it('returns zero balance when payments exceed term', function () { + $remaining = $this->service->calculateRemainingBalance(20000000, 3.5, 360, 400); + + expect($remaining)->toBe(0); +}); + +it('returns full principal when no payments made', function () { + $remaining = $this->service->calculateRemainingBalance(20000000, 3.5, 360, 0); + + expect($remaining)->toBe(20000000); +}); + +it('calculates remaining balance for zero interest loan', function () { + // $120,000 at 0% for 120 months, after 60 payments = $60,000 remaining + $remaining = $this->service->calculateRemainingBalance(12000000, 0, 120, 60); + + expect($remaining)->toBe(6000000); +}); + +it('projects future balances from a known balance point', function () { + $projection = $this->service->projectFromBalance( + 20000000, + now()->startOfMonth(), + 3.5, + 360, + 6, + ); + + expect($projection)->toHaveCount(6); + + // Each subsequent month should have a lower balance + $values = array_values($projection); + for ($i = 1; $i < count($values); $i++) { + expect($values[$i])->toBeLessThan($values[$i - 1]); + } +}); + +it('limits projection to remaining months when fewer than requested', function () { + $projection = $this->service->projectFromBalance( + 100000, + now()->startOfMonth(), + 3.5, + 3, + 12, + ); + + expect($projection)->toHaveCount(3); +}); + +// Tests for calculateRemainingMonths and getBalanceAtDate are in +// tests/Feature/LoanTest.php because they require Eloquent model +// instantiation which needs a database connection (HasUuids trait).