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:
parent
fa11dc78e0
commit
bb65bdc16e
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -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' => [
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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');
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -56,4 +56,11 @@ class AccountFactory extends Factory
|
|||
'bank_id' => null,
|
||||
]);
|
||||
}
|
||||
|
||||
public function loan(): static
|
||||
{
|
||||
return $this->state(fn (array $attributes) => [
|
||||
'type' => AccountType::Loan,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -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');
|
||||
}
|
||||
};
|
||||
18
lang/es.json
18
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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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: () => {
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
});
|
||||
|
|
@ -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',
|
||||
]);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
Loading…
Reference in New Issue