1022 lines
32 KiB
PHP
1022 lines
32 KiB
PHP
<?php
|
|
|
|
use App\Enums\AccountType;
|
|
use App\Models\Account;
|
|
use App\Models\AccountBalance;
|
|
use App\Models\Bank;
|
|
use App\Models\LoanDetail;
|
|
use App\Models\RealEstateDetail;
|
|
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 () {
|
|
// The balance-evolution endpoint converts account currency via the external
|
|
// currency-rate provider; fake it so these tests stay hermetic under the
|
|
// stray-request guard instead of hitting the CDN.
|
|
fakeCurrencyApi();
|
|
|
|
$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('projects from existing balance entries instead of original loan params', 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(138),
|
|
'loan_term_months' => 333,
|
|
'annual_interest_rate' => 4.110,
|
|
'original_amount' => 7991346,
|
|
]);
|
|
|
|
// Simulate a real balance that's lower than the theoretical amortization schedule
|
|
// (e.g. the user has been making extra payments)
|
|
AccountBalance::create([
|
|
'account_id' => $account->id,
|
|
'balance_date' => now()->startOfMonth()->subMonth()->toDateString(),
|
|
'balance' => 4489670,
|
|
]);
|
|
|
|
$balance = $this->service->getBalanceAtDate($loanDetail, now());
|
|
|
|
// The result should project from 4,489,670, NOT recalculate from original 7,991,346.
|
|
// Theoretical from original params would give ~5,700,000+ which is wrong.
|
|
expect($balance)->toBeLessThan(4489670)
|
|
->toBeGreaterThan(4400000);
|
|
});
|
|
|
|
it('returns existing balance when target date is in the same month', 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()->startOfMonth()->toDateString(),
|
|
'balance' => 19500000,
|
|
]);
|
|
|
|
$balance = $this->service->getBalanceAtDate($loanDetail, now());
|
|
|
|
expect($balance)->toBe(19500000);
|
|
});
|
|
|
|
it('generates correct monthly balance when existing entries differ from theoretical', 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(138),
|
|
'loan_term_months' => 333,
|
|
'annual_interest_rate' => 4.110,
|
|
'original_amount' => 7991346,
|
|
]);
|
|
|
|
// Balance from last month that's lower than theoretical schedule
|
|
AccountBalance::create([
|
|
'account_id' => $account->id,
|
|
'balance_date' => now()->startOfMonth()->subMonth()->toDateString(),
|
|
'balance' => 4489670,
|
|
]);
|
|
|
|
artisan('loans:generate-balances')->assertSuccessful();
|
|
|
|
$generated = AccountBalance::query()
|
|
->where('account_id', $account->id)
|
|
->where('balance_date', now()->startOfMonth()->toDateString())
|
|
->first();
|
|
|
|
expect($generated)->not->toBeNull();
|
|
|
|
// The generated balance should be projected from the real 4,489,670
|
|
// and should be slightly lower (one month of amortization), not jump up
|
|
expect($generated->balance)->toBeLessThan(4489670)
|
|
->toBeGreaterThan(4400000);
|
|
});
|
|
|
|
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()->startOfMonth()->subMonth()->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 linked to an existing unlinked real estate account', function () {
|
|
actingAs($this->user);
|
|
|
|
$realEstateAccount = Account::factory()->realEstate()->create([
|
|
'user_id' => $this->user->id,
|
|
'currency_code' => 'USD',
|
|
]);
|
|
|
|
RealEstateDetail::factory()->create([
|
|
'account_id' => $realEstateAccount->id,
|
|
]);
|
|
|
|
$response = $this->post(route('accounts.store'), [
|
|
'name' => 'Mortgage Loan',
|
|
'bank_id' => $this->bank->id,
|
|
'currency_code' => 'USD',
|
|
'type' => AccountType::Loan->value,
|
|
'linked_real_estate_account_id' => $realEstateAccount->id,
|
|
'annual_interest_rate' => 3.5,
|
|
'loan_term_months' => 360,
|
|
'original_amount' => 20000000,
|
|
]);
|
|
|
|
$response->assertRedirect();
|
|
|
|
$loanAccount = Account::query()
|
|
->where('user_id', $this->user->id)
|
|
->where('type', AccountType::Loan->value)
|
|
->where('name', 'Mortgage Loan')
|
|
->first();
|
|
|
|
expect($loanAccount)->not->toBeNull();
|
|
|
|
assertDatabaseHas('real_estate_details', [
|
|
'account_id' => $realEstateAccount->id,
|
|
'linked_loan_account_id' => $loanAccount->id,
|
|
]);
|
|
});
|
|
|
|
it('validates linked_real_estate_account_id must be a real estate account owned by the user', function () {
|
|
actingAs($this->user);
|
|
|
|
$checkingAccount = Account::factory()->create([
|
|
'user_id' => $this->user->id,
|
|
'type' => AccountType::Checking,
|
|
]);
|
|
|
|
$response = $this->post(route('accounts.store'), [
|
|
'name' => 'Mortgage Loan',
|
|
'bank_id' => $this->bank->id,
|
|
'currency_code' => 'USD',
|
|
'type' => AccountType::Loan->value,
|
|
'linked_real_estate_account_id' => $checkingAccount->id,
|
|
]);
|
|
|
|
$response->assertSessionHasErrors(['linked_real_estate_account_id']);
|
|
});
|
|
|
|
it('validates linked_real_estate_account_id cannot be another users property', function () {
|
|
actingAs($this->user);
|
|
|
|
$otherUser = User::factory()->create();
|
|
$otherRealEstateAccount = Account::factory()->realEstate()->create([
|
|
'user_id' => $otherUser->id,
|
|
]);
|
|
|
|
RealEstateDetail::factory()->create([
|
|
'account_id' => $otherRealEstateAccount->id,
|
|
]);
|
|
|
|
$response = $this->post(route('accounts.store'), [
|
|
'name' => 'Mortgage Loan',
|
|
'bank_id' => $this->bank->id,
|
|
'currency_code' => 'USD',
|
|
'type' => AccountType::Loan->value,
|
|
'linked_real_estate_account_id' => $otherRealEstateAccount->id,
|
|
]);
|
|
|
|
$response->assertSessionHasErrors(['linked_real_estate_account_id']);
|
|
});
|
|
|
|
it('validates linked_real_estate_account_id cannot be an already linked property', function () {
|
|
actingAs($this->user);
|
|
|
|
$existingLoan = Account::factory()->loan()->create([
|
|
'user_id' => $this->user->id,
|
|
'bank_id' => $this->bank->id,
|
|
]);
|
|
|
|
$realEstateAccount = Account::factory()->realEstate()->create([
|
|
'user_id' => $this->user->id,
|
|
]);
|
|
|
|
RealEstateDetail::factory()->create([
|
|
'account_id' => $realEstateAccount->id,
|
|
'linked_loan_account_id' => $existingLoan->id,
|
|
]);
|
|
|
|
$response = $this->post(route('accounts.store'), [
|
|
'name' => 'Mortgage Loan',
|
|
'bank_id' => $this->bank->id,
|
|
'currency_code' => 'USD',
|
|
'type' => AccountType::Loan->value,
|
|
'linked_real_estate_account_id' => $realEstateAccount->id,
|
|
]);
|
|
|
|
$response->assertSessionHasErrors(['linked_real_estate_account_id']);
|
|
});
|
|
|
|
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()->startOfMonth()->subMonth()->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()->startOfMonth()->subMonth()->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]);
|
|
});
|