whisper-money/tests/Feature/SharedAccountOwnershipTest.php

277 lines
9.3 KiB
PHP

<?php
use App\Enums\AccountType;
use App\Enums\CategoryType;
use App\Models\Account;
use App\Models\AccountBalance;
use App\Models\Bank;
use App\Models\Category;
use App\Models\Transaction;
use App\Models\User;
use Illuminate\Support\Facades\Http;
beforeEach(function () {
Http::fake();
$this->user = User::factory()->onboarded()->create(['currency_code' => 'USD']);
$this->actingAs($this->user);
});
/**
* A checking account owned at the given percentage, with one income and one
* expense transaction dated today.
*/
function sharedAccountWithTransactions(User $user, int $percentage, bool $appliesToBalance = false): Account
{
$account = Account::factory()->create([
'user_id' => $user->id,
'type' => AccountType::Checking,
'currency_code' => 'USD',
'ownership_percentage' => $percentage,
'ownership_applies_to_balance' => $appliesToBalance,
]);
$income = Category::factory()->create([
'user_id' => $user->id,
'type' => CategoryType::Income,
]);
$expense = Category::factory()->create([
'user_id' => $user->id,
'type' => CategoryType::Expense,
]);
Transaction::factory()->create([
'user_id' => $user->id,
'account_id' => $account->id,
'category_id' => $income->id,
'amount' => 200000,
'currency_code' => 'USD',
'transaction_date' => now(),
]);
Transaction::factory()->create([
'user_id' => $user->id,
'account_id' => $account->id,
'category_id' => $expense->id,
'amount' => -80000,
'currency_code' => 'USD',
'transaction_date' => now(),
]);
return $account;
}
test('accounts are fully owned by default', function () {
$account = Account::factory()->create(['user_id' => $this->user->id]);
expect($account->fresh()->ownership_percentage)->toBe(100)
->and($account->fresh()->ownership_applies_to_balance)->toBeFalse();
});
test('the cashflow summary endpoint counts only the owner share of a shared account', function () {
sharedAccountWithTransactions($this->user, 50);
$response = $this->getJson('/api/cashflow/summary?'.http_build_query([
'from' => now()->startOfMonth()->toDateString(),
'to' => now()->endOfMonth()->toDateString(),
]));
$response->assertOk();
expect($response->json('current.income'))->toBe(100000)
->and($response->json('current.expense'))->toBe(40000)
->and($response->json('current.net'))->toBe(60000);
});
test('the cashflow breakdown endpoint counts only the owner share of a shared account', function () {
sharedAccountWithTransactions($this->user, 50);
$response = $this->getJson('/api/cashflow/breakdown?'.http_build_query([
'from' => now()->startOfMonth()->toDateString(),
'to' => now()->endOfMonth()->toDateString(),
'type' => 'expense',
]));
$response->assertOk();
expect($response->json('total'))->toBe(40000);
});
test('the dashboard cashflow endpoint counts only the owner share of a shared account', function () {
sharedAccountWithTransactions($this->user, 50);
$response = $this->getJson('/api/dashboard/cash-flow?'.http_build_query([
'from' => now()->startOfMonth()->toDateString(),
'to' => now()->endOfMonth()->toDateString(),
]));
$response->assertOk();
expect($response->json('current.income'))->toBe(100000)
->and($response->json('current.expense'))->toBe(40000);
});
test('the dashboard monthly spending endpoint counts only the owner share of a shared account', function () {
sharedAccountWithTransactions($this->user, 50);
$response = $this->getJson('/api/dashboard/monthly-spending?'.http_build_query([
'from' => now()->startOfMonth()->toDateString(),
'to' => now()->endOfMonth()->toDateString(),
]));
$response->assertOk();
expect($response->json('current'))->toBe(40000);
});
test('the dashboard top categories endpoint counts only the owner share of a shared account', function () {
sharedAccountWithTransactions($this->user, 50);
$response = $this->getJson('/api/dashboard/top-categories?'.http_build_query([
'from' => now()->startOfMonth()->toDateString(),
'to' => now()->endOfMonth()->toDateString(),
]));
$response->assertOk();
expect($response->json('0.amount'))->toBe(40000);
});
test('the dashboard page cashflow summary counts only the owner share of a shared account', function () {
sharedAccountWithTransactions($this->user, 50);
$response = $this->withoutVite()->get(route('dashboard'), [
'X-Inertia' => 'true',
'X-Inertia-Partial-Component' => 'dashboard',
'X-Inertia-Partial-Data' => 'cashflowSummary',
]);
$response->assertOk();
expect($response->json('props.cashflowSummary.current.income'))->toBe(100000)
->and($response->json('props.cashflowSummary.current.expense'))->toBe(40000);
});
/**
* The share is computed twice — in PHP for the row-by-row cashflow paths and in
* SQL for the dashboard aggregates. An amount that does not divide evenly is the
* only thing that catches the two rounding rules drifting apart.
*/
test('the PHP and SQL paths round an uneven share identically', function () {
$account = Account::factory()->create([
'user_id' => $this->user->id,
'type' => AccountType::Checking,
'currency_code' => 'USD',
'ownership_percentage' => 33,
]);
$expense = Category::factory()->create([
'user_id' => $this->user->id,
'type' => CategoryType::Expense,
]);
// 33% of 3333 is 1099.89, which has to land on the same side in both paths.
Transaction::factory()->create([
'user_id' => $this->user->id,
'account_id' => $account->id,
'category_id' => $expense->id,
'amount' => -3333,
'currency_code' => 'USD',
'transaction_date' => now(),
]);
$query = http_build_query([
'from' => now()->startOfMonth()->toDateString(),
'to' => now()->endOfMonth()->toDateString(),
]);
$php = $this->getJson('/api/cashflow/summary?'.$query);
$sql = $this->getJson('/api/dashboard/monthly-spending?'.$query);
expect($php->json('current.expense'))->toBe(1100)
->and($sql->json('current'))->toBe(1100);
});
test('fully owned accounts are unaffected', function () {
sharedAccountWithTransactions($this->user, 100);
$response = $this->getJson('/api/cashflow/summary?'.http_build_query([
'from' => now()->startOfMonth()->toDateString(),
'to' => now()->endOfMonth()->toDateString(),
]));
$response->assertOk();
expect($response->json('current.income'))->toBe(200000)
->and($response->json('current.expense'))->toBe(80000);
});
test('net worth keeps the full balance when the share is limited to transactions', function () {
$account = sharedAccountWithTransactions($this->user, 50);
AccountBalance::factory()->create([
'account_id' => $account->id,
'balance_date' => now(),
'balance' => 300000,
]);
$response = $this->getJson('/api/dashboard/net-worth?'.http_build_query([
'from' => now()->startOfMonth()->toDateString(),
'to' => now()->endOfMonth()->toDateString(),
]));
$response->assertOk();
expect($response->json('current'))->toBe(300000);
});
test('net worth counts only the owner share when the account applies it to the balance', function () {
$account = sharedAccountWithTransactions($this->user, 50, appliesToBalance: true);
AccountBalance::factory()->create([
'account_id' => $account->id,
'balance_date' => now(),
'balance' => 300000,
]);
$response = $this->getJson('/api/dashboard/net-worth?'.http_build_query([
'from' => now()->startOfMonth()->toDateString(),
'to' => now()->endOfMonth()->toDateString(),
]));
$response->assertOk();
expect($response->json('current'))->toBe(150000);
});
test('updating an account persists its ownership settings', function () {
$bank = Bank::factory()->create();
$account = Account::factory()->create([
'user_id' => $this->user->id,
'bank_id' => $bank->id,
'type' => AccountType::Checking,
'currency_code' => 'USD',
]);
$this->patch(route('accounts.update', $account), [
'name' => 'Joint account',
'bank_id' => $bank->id,
'type' => AccountType::Checking->value,
'currency_code' => 'USD',
'ownership_percentage' => 50,
'ownership_applies_to_balance' => true,
])->assertRedirect();
expect($account->fresh()->ownership_percentage)->toBe(50)
->and($account->fresh()->ownership_applies_to_balance)->toBeTrue();
});
test('the ownership percentage must stay between 1 and 100', function () {
$bank = Bank::factory()->create();
$account = Account::factory()->create([
'user_id' => $this->user->id,
'bank_id' => $bank->id,
'type' => AccountType::Checking,
'currency_code' => 'USD',
]);
$this->patch(route('accounts.update', $account), [
'name' => 'Joint account',
'bank_id' => $bank->id,
'type' => AccountType::Checking->value,
'currency_code' => 'USD',
'ownership_percentage' => 120,
])->assertSessionHasErrors('ownership_percentage');
expect($account->fresh()->ownership_percentage)->toBe(100);
});