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`
This commit is contained in:
Víctor Falcón 2026-03-26 14:06:09 +00:00 committed by GitHub
parent fa11dc78e0
commit bb65bdc16e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
27 changed files with 2417 additions and 22 deletions

View File

@ -0,0 +1,70 @@
<?php
namespace App\Console\Commands;
use App\Enums\AccountType;
use App\Models\Account;
use App\Models\AccountBalance;
use App\Services\LoanAmortizationService;
use Illuminate\Console\Command;
class GenerateMonthlyLoanBalances extends Command
{
protected $signature = 'loans:generate-balances';
protected $description = 'Generate monthly balance entries for loan accounts with amortization details';
public function __construct(protected LoanAmortizationService $amortizationService)
{
parent::__construct();
}
public function handle(): int
{
$this->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;
}
}

View File

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

View File

@ -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' => [

View File

@ -0,0 +1,46 @@
<?php
namespace App\Http\Controllers;
use App\Http\Requests\UpdateLoanDetailRequest;
use App\Models\Account;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Http\RedirectResponse;
class LoanDetailController extends Controller
{
use AuthorizesRequests;
/**
* Update the loan detail for an account.
*/
public function update(UpdateLoanDetailRequest $request, Account $account): RedirectResponse
{
$this->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);
}
}

View File

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

View File

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

View File

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

View File

@ -0,0 +1,32 @@
<?php
namespace App\Http\Requests;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
class UpdateLoanDetailRequest 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 [
'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'],
];
}
}

View File

@ -79,6 +79,12 @@ class Account extends Model
return $this->hasOne(RealEstateDetail::class);
}
/** @return HasOne<LoanDetail, $this> */
public function loanDetail(): HasOne
{
return $this->hasOne(LoanDetail::class);
}
public function isConnected(): bool
{
return $this->banking_connection_id !== null;

46
app/Models/LoanDetail.php Normal file
View File

@ -0,0 +1,46 @@
<?php
namespace App\Models;
use Database\Factories\LoanDetailFactory;
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 float $annual_interest_rate
* @property int $loan_term_months
* @property Carbon $start_date
* @property int $original_amount
*/
class LoanDetail extends Model
{
/** @use HasFactory<LoanDetailFactory> */
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<Account, $this> */
public function account(): BelongsTo
{
return $this->belongsTo(Account::class);
}
}

View File

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

View File

@ -0,0 +1,216 @@
<?php
namespace App\Services;
use App\Models\AccountBalance;
use App\Models\LoanDetail;
use Carbon\Carbon;
class LoanAmortizationService
{
/**
* Calculate the fixed monthly payment for a loan.
*
* Uses the standard amortization formula:
* M = P * [r(1+r)^n] / [(1+r)^n - 1]
*
* @param int $principalCents Original loan amount in cents
* @param float $annualRate Annual interest rate as percentage (e.g. 3.5)
* @param int $termMonths Total loan term in months
* @return int Monthly payment in cents
*/
public function calculateMonthlyPayment(int $principalCents, float $annualRate, int $termMonths): int
{
if ($termMonths <= 0) {
return 0;
}
$principal = $principalCents / 100;
$monthlyRate = ($annualRate / 100) / 12;
if ($monthlyRate === 0.0) {
return (int) round(($principal / $termMonths) * 100);
}
$factor = pow(1 + $monthlyRate, $termMonths);
$payment = $principal * ($monthlyRate * $factor) / ($factor - 1);
return (int) round($payment * 100);
}
/**
* Calculate the remaining balance after a given number of payments.
*
* Uses the formula: B(k) = P * [(1+r)^n - (1+r)^k] / [(1+r)^n - 1]
*
* @param int $principalCents Original loan amount in cents
* @param float $annualRate Annual interest rate as percentage (e.g. 3.5)
* @param int $termMonths Total loan term in months
* @param int $paymentsMade Number of payments already made
* @return int Remaining balance in cents
*/
public function calculateRemainingBalance(int $principalCents, float $annualRate, int $termMonths, int $paymentsMade): int
{
if ($paymentsMade >= $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<string, int> 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<string, int> 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<string, int> 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,
);
}
}

View File

@ -56,4 +56,11 @@ class AccountFactory extends Factory
'bank_id' => null,
]);
}
public function loan(): static
{
return $this->state(fn (array $attributes) => [
'type' => AccountType::Loan,
]);
}
}

View File

@ -0,0 +1,30 @@
<?php
namespace Database\Factories;
use App\Enums\AccountType;
use App\Models\Account;
use App\Models\LoanDetail;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends Factory<LoanDetail>
*/
class LoanDetailFactory extends Factory
{
/**
* Define the model's default state.
*
* @return array<string, mixed>
*/
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),
];
}
}

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::create('loan_details', function (Blueprint $table) {
$table->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');
}
};

View File

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

View File

@ -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
/>
</ComposedChart>
) : hasProjectedData ? (
<ComposedChart
accessibilityLayer
data={chartData.slice(1)}
>
<defs>
<linearGradient
id="fillBalance"
x1="0"
y1="0"
x2="0"
y2="1"
>
<stop
offset="5%"
stopColor="var(--color-chart-2)"
stopOpacity={0.3}
/>
<stop
offset="95%"
stopColor="var(--color-chart-2)"
stopOpacity={0.05}
/>
</linearGradient>
</defs>
<XAxis
dataKey="month"
tickLine={false}
tickMargin={10}
axisLine={false}
tickFormatter={formatXAxisLabel}
/>
<ChartTooltip
content={
<ChartTooltipContent
hideLabel
valueFormatter={valueFormatter}
/>
}
/>
<Area
dataKey="value"
type="monotone"
fill="url(#fillBalance)"
stroke="var(--color-chart-2)"
strokeWidth={2}
dot={false}
activeDot={{ r: 5 }}
fillOpacity={1}
/>
<Line
dataKey="projected_value"
type="monotone"
stroke="var(--color-chart-2)"
strokeWidth={2}
strokeDasharray="6 4"
dot={false}
activeDot={{ r: 4 }}
connectNulls
/>
</ComposedChart>
) : (
<BarChart
accessibilityLayer

View File

@ -1,3 +1,4 @@
import InputError from '@/components/input-error';
import { AmountInput } from '@/components/ui/amount-input';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
@ -50,6 +51,13 @@ export interface RealEstateFormData {
revaluationPercentage: string;
}
export interface LoanFormData {
annualInterestRate: string;
loanTermMonths: string;
startDate: string;
originalAmount: number;
}
export interface AccountFormData {
displayName: string;
bankId: number | null;
@ -58,6 +66,7 @@ export interface AccountFormData {
customBank: CustomBankData | null;
balance: number | null;
realEstate: RealEstateFormData | null;
loan: LoanFormData | null;
}
interface AccountFormProps {
@ -66,11 +75,14 @@ interface AccountFormProps {
bank: Bank | null;
type: AccountType;
currencyCode: CurrencyCode;
loan?: LoanFormData | null;
realEstate?: RealEstateFormData | null;
};
forceAccountType?: AccountType;
hiddenAccountTypes?: AccountType[];
availableLoanAccounts?: Account[];
onChange: (data: AccountFormData) => void;
errors?: Record<string, string>;
}
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<number | null>(null);
const [realEstateData, setRealEstateData] = useState<RealEstateFormData>(
initialRealEstateData,
initialValues?.realEstate ?? initialRealEstateData,
);
const [loanData, setLoanData] = useState<LoanFormData>(
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({
</div>
</div>
{showBalanceField && selectedCurrency && (
{showBalanceField && selectedCurrency && !initialValues && (
<div className="space-y-2">
<Label htmlFor="balance">
{balanceTermCapitalized(selectedType!)}
@ -319,6 +349,103 @@ export function AccountForm({
</div>
)}
{isLoan && selectedCurrency && (
<>
<div className="space-y-2">
<Label htmlFor="annual_interest_rate">
{__('Annual Interest Rate (%)')}
</Label>
<Input
id="annual_interest_rate"
type="number"
className="mt-1"
value={loanData.annualInterestRate}
onChange={(e) =>
setLoanData((prev) => ({
...prev,
annualInterestRate: e.target.value,
}))
}
placeholder="3.5"
min="0"
max="100"
step="0.001"
/>
<InputError message={errors.annual_interest_rate} />
</div>
<div className="space-y-2">
<Label htmlFor="loan_term_months">
{__('Loan Term (months)')}
</Label>
<Input
id="loan_term_months"
type="number"
className="mt-1"
value={loanData.loanTermMonths}
onChange={(e) =>
setLoanData((prev) => ({
...prev,
loanTermMonths: e.target.value,
}))
}
placeholder="360"
min="1"
max="600"
/>
<InputError message={errors.loan_term_months} />
<p className="pl-1 text-xs text-muted-foreground">
{__('e.g. 360 for a 30-year mortgage')}
</p>
</div>
<div className="space-y-2">
<Label htmlFor="loan_start_date">
{__('Loan Start Date')}
</Label>
<Input
id="loan_start_date"
type="date"
className="mt-1"
value={loanData.startDate}
onChange={(e) =>
setLoanData((prev) => ({
...prev,
startDate: e.target.value,
}))
}
/>
<InputError message={errors.loan_start_date} />
</div>
<div className="space-y-2">
<Label htmlFor="original_amount">
{__('Original Loan Amount')}
</Label>
<div className="mt-1">
<AmountInput
id="original_amount"
value={loanData.originalAmount}
onChange={(value) =>
setLoanData((prev) => ({
...prev,
originalAmount: value,
}))
}
currencyCode={selectedCurrency}
/>
</div>
<InputError message={errors.original_amount} />
</div>
<p className="pl-1 text-xs text-muted-foreground">
{__(
'Optional. Provide loan details to automatically project your owed amount over time.',
)}
</p>
</>
)}
{isRealEstate && (
<>
<div className="space-y-2">

View File

@ -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: () => {

View File

@ -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<Record<string, string>>({});
const formDataRef = useRef<AccountFormData>({
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<string | null> {
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({
<AccountForm
initialValues={initialValues}
onChange={handleFormChange}
errors={errors}
/>
) : (
<div className="space-y-4">

View File

@ -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<ChartComputedData | null>(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 && (
<LoanDetailsCard
detail={loanDetail ?? null}
account={account}
isEditing={editingLoanDetails}
onEditToggle={setEditingLoanDetails}
/>
)}
{isTransactionalAccount(account) && (
<TransactionList
categories={categories}
@ -537,7 +552,7 @@ function PropertyDetailsCard({
onEditToggle,
}: {
detail: RealEstateDetail;
account: AccountWithRealEstate;
account: AccountWithDetails;
availableLoanAccounts: Account[];
isEditing: boolean;
onEditToggle: (editing: boolean) => void;
@ -969,3 +984,297 @@ function PropertyDetailsCard({
</Card>
);
}
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<Record<string, string>>({});
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 (
<Card>
<CardHeader>
<CardTitle>{__('Edit Loan 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_annual_interest_rate">
{__('Annual Interest Rate (%)')}
</Label>
<Input
id="edit_annual_interest_rate"
type="number"
value={formData.annual_interest_rate}
onChange={(e) =>
setFormData((prev) => ({
...prev,
annual_interest_rate:
e.target.value,
}))
}
placeholder="3.500"
min="0"
max="99.999"
step="0.001"
/>
<InputError
message={errors.annual_interest_rate}
/>
</div>
<div className="space-y-2">
<Label htmlFor="edit_loan_term_months">
{__('Loan Term (months)')}
</Label>
<Input
id="edit_loan_term_months"
type="number"
value={formData.loan_term_months}
onChange={(e) =>
setFormData((prev) => ({
...prev,
loan_term_months: e.target.value,
}))
}
placeholder="360"
min="1"
max="600"
/>
<InputError message={errors.loan_term_months} />
</div>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="edit_loan_start_date">
{__('Start Date')}
</Label>
<Input
id="edit_loan_start_date"
type="date"
value={formData.start_date}
onChange={(e) =>
setFormData((prev) => ({
...prev,
start_date: e.target.value,
}))
}
/>
<InputError message={errors.start_date} />
</div>
<div className="space-y-2">
<Label htmlFor="edit_original_amount">
{__('Original Amount')}
</Label>
<AmountInput
id="edit_original_amount"
value={formData.original_amount}
onChange={(value) =>
setFormData((prev) => ({
...prev,
original_amount: value,
}))
}
currencyCode={account.currency_code}
/>
<InputError message={errors.original_amount} />
</div>
</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>
);
}
if (!detail) {
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle>{__('Loan Details')}</CardTitle>
<Button
variant="ghost"
size="sm"
onClick={() => onEditToggle(true)}
>
<Pencil className="mr-1 h-3.5 w-3.5" />
{__('Add')}
</Button>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">
{__(
'No loan details yet. Add interest rate, term, and amount to track amortization.',
)}
</p>
</CardContent>
</Card>
);
}
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle>{__('Loan 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">
{__('Annual Interest Rate')}
</dt>
<dd className="font-medium">
{detail.annual_interest_rate}%
</dd>
</div>
<div>
<dt className="text-sm text-muted-foreground">
{__('Loan Term')}
</dt>
<dd className="font-medium">
{detail.loan_term_months} {__('months')}
{detail.loan_term_months >= 12 && (
<span className="ml-1 text-sm text-muted-foreground">
({Math.floor(detail.loan_term_months / 12)}{' '}
{__('years')})
</span>
)}
</dd>
</div>
<div>
<dt className="text-sm text-muted-foreground">
{__('Start Date')}
</dt>
<dd className="font-medium">
{formatDateMedium(detail.start_date)}
</dd>
</div>
<div>
<dt className="text-sm text-muted-foreground">
{__('Original Amount')}
</dt>
<dd className="font-medium">
<AmountDisplay
amountInCents={detail.original_amount}
currencyCode={account.currency_code}
/>
</dd>
</div>
{detail.monthly_payment !== null && (
<div>
<dt className="text-sm text-muted-foreground">
{__('Monthly Payment')}
</dt>
<dd className="font-medium">
<AmountDisplay
amountInCents={detail.monthly_payment}
currencyCode={account.currency_code}
/>
</dd>
</div>
)}
{detail.remaining_months !== null && (
<div>
<dt className="text-sm text-muted-foreground">
{__('Remaining')}
</dt>
<dd className="font-medium">
{detail.remaining_months} {__('months')}
{detail.remaining_months >= 12 && (
<span className="ml-1 text-sm text-muted-foreground">
(
{Math.floor(
detail.remaining_months / 12,
)}{' '}
{__('years')})
</span>
)}
</dd>
</div>
)}
</dl>
</CardContent>
</Card>
);
}

View File

@ -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<PropertyType, string> = {
residential: __('Residential'),
@ -138,6 +148,7 @@ export function formatAccountType(type: AccountType): string {
const NON_TRANSACTIONAL_ACCOUNT_TYPES: AccountType[] = [
'investment',
'loan',
'real_estate',
'retirement',
];

View File

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

View File

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

823
tests/Feature/LoanTest.php Normal file
View File

@ -0,0 +1,823 @@
<?php
use App\Enums\AccountType;
use App\Models\Account;
use App\Models\AccountBalance;
use App\Models\Bank;
use App\Models\LoanDetail;
use App\Models\User;
use App\Services\LoanAmortizationService;
use function Pest\Laravel\actingAs;
use function Pest\Laravel\artisan;
use function Pest\Laravel\assertDatabaseHas;
use function Pest\Laravel\assertDatabaseMissing;
beforeEach(function () {
$this->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]);
});

View File

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

View File

@ -0,0 +1,97 @@
<?php
use App\Services\LoanAmortizationService;
beforeEach(function () {
$this->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).