1542 lines
51 KiB
PHP
1542 lines
51 KiB
PHP
<?php
|
|
|
|
use App\Enums\CategoryCashflowDirection;
|
|
use App\Enums\CategoryType;
|
|
use App\Models\Account;
|
|
use App\Models\Category;
|
|
use App\Models\ExchangeRate;
|
|
use App\Models\Transaction;
|
|
use App\Models\User;
|
|
use Illuminate\Support\Facades\Http;
|
|
|
|
beforeEach(function () {
|
|
Http::fake();
|
|
|
|
$this->user = User::factory()->create();
|
|
$this->actingAs($this->user);
|
|
});
|
|
|
|
test('cashflow analytics responses are not cached between users', function () {
|
|
$response = $this->getJson('/api/cashflow/summary?'.http_build_query([
|
|
'from' => now()->startOfMonth()->toDateString(),
|
|
'to' => now()->endOfMonth()->toDateString(),
|
|
]));
|
|
|
|
$response->assertOk()
|
|
->assertHeader('Cache-Control', 'no-store, private');
|
|
});
|
|
|
|
test('cashflow summary returns income, expense, net, and savings rate', function () {
|
|
$incomeCategory = Category::factory()->create([
|
|
'user_id' => $this->user->id,
|
|
'type' => CategoryType::Income,
|
|
]);
|
|
$expenseCategory = Category::factory()->create([
|
|
'user_id' => $this->user->id,
|
|
'type' => CategoryType::Expense,
|
|
]);
|
|
|
|
$account = Account::factory()->create(['user_id' => $this->user->id]);
|
|
|
|
// Income: $1000
|
|
Transaction::factory()->create([
|
|
'user_id' => $this->user->id,
|
|
'account_id' => $account->id,
|
|
'category_id' => $incomeCategory->id,
|
|
'amount' => 100000,
|
|
'transaction_date' => now(),
|
|
]);
|
|
|
|
// Expense: $400
|
|
Transaction::factory()->create([
|
|
'user_id' => $this->user->id,
|
|
'account_id' => $account->id,
|
|
'category_id' => $expenseCategory->id,
|
|
'amount' => -40000,
|
|
'transaction_date' => now(),
|
|
]);
|
|
|
|
$response = $this->getJson('/api/cashflow/summary?'.http_build_query([
|
|
'from' => now()->startOfMonth()->toDateString(),
|
|
'to' => now()->endOfMonth()->toDateString(),
|
|
]));
|
|
|
|
$response->assertOk()
|
|
->assertJson([
|
|
'current' => [
|
|
'income' => 100000,
|
|
'expense' => 40000,
|
|
'net' => 60000,
|
|
'savings_rate' => 60.0, // (1000 - 400) / 1000 * 100 = 60%
|
|
],
|
|
]);
|
|
});
|
|
|
|
test('cashflow analytics convert foreign currency transactions to user currency', function () {
|
|
$this->travelTo(now()->startOfMonth()->addDays(14));
|
|
|
|
$date = now()->startOfMonth()->addDays(4);
|
|
$from = $date->copy()->startOfMonth()->toDateString();
|
|
$to = $date->copy()->endOfMonth()->toDateString();
|
|
|
|
$incomeCategory = Category::factory()->create([
|
|
'user_id' => $this->user->id,
|
|
'type' => CategoryType::Income,
|
|
]);
|
|
$expenseCategory = Category::factory()->create([
|
|
'user_id' => $this->user->id,
|
|
'type' => CategoryType::Expense,
|
|
]);
|
|
|
|
$usdAccount = Account::factory()->create([
|
|
'user_id' => $this->user->id,
|
|
'currency_code' => 'USD',
|
|
]);
|
|
$eurAccount = Account::factory()->create([
|
|
'user_id' => $this->user->id,
|
|
'currency_code' => 'EUR',
|
|
]);
|
|
|
|
ExchangeRate::factory()->create([
|
|
'base_currency' => 'usd',
|
|
'date' => $date->toDateString(),
|
|
'rates' => ['eur' => 0.80],
|
|
]);
|
|
|
|
Transaction::factory()->create([
|
|
'user_id' => $this->user->id,
|
|
'account_id' => $usdAccount->id,
|
|
'category_id' => $incomeCategory->id,
|
|
'amount' => 10000,
|
|
'currency_code' => 'USD',
|
|
'transaction_date' => $date,
|
|
]);
|
|
Transaction::factory()->create([
|
|
'user_id' => $this->user->id,
|
|
'account_id' => $eurAccount->id,
|
|
'category_id' => $incomeCategory->id,
|
|
'amount' => 8000,
|
|
'currency_code' => 'EUR',
|
|
'transaction_date' => $date,
|
|
]);
|
|
Transaction::factory()->create([
|
|
'user_id' => $this->user->id,
|
|
'account_id' => $usdAccount->id,
|
|
'category_id' => $expenseCategory->id,
|
|
'amount' => -5000,
|
|
'currency_code' => 'USD',
|
|
'transaction_date' => $date,
|
|
]);
|
|
Transaction::factory()->create([
|
|
'user_id' => $this->user->id,
|
|
'account_id' => $eurAccount->id,
|
|
'category_id' => $expenseCategory->id,
|
|
'amount' => -4000,
|
|
'currency_code' => 'EUR',
|
|
'transaction_date' => $date,
|
|
]);
|
|
|
|
$summary = $this->getJson('/api/cashflow/summary?'.http_build_query([
|
|
'from' => $from,
|
|
'to' => $to,
|
|
]));
|
|
$trend = $this->getJson('/api/cashflow/trend?'.http_build_query([
|
|
'months' => 1,
|
|
'to' => $to,
|
|
]));
|
|
$sankey = $this->getJson('/api/cashflow/sankey?'.http_build_query([
|
|
'from' => $from,
|
|
'to' => $to,
|
|
]));
|
|
$incomeBreakdown = $this->getJson('/api/cashflow/breakdown?'.http_build_query([
|
|
'from' => $from,
|
|
'to' => $to,
|
|
'type' => 'income',
|
|
]));
|
|
$expenseBreakdown = $this->getJson('/api/cashflow/breakdown?'.http_build_query([
|
|
'from' => $from,
|
|
'to' => $to,
|
|
'type' => 'expense',
|
|
]));
|
|
|
|
$summary->assertOk()
|
|
->assertJsonPath('current.income', 20000)
|
|
->assertJsonPath('current.expense', 10000)
|
|
->assertJsonPath('current.net', 10000)
|
|
->assertJsonPath('current.savings_rate', 50);
|
|
|
|
$trend->assertOk()
|
|
->assertJsonPath('data.0.income', 20000)
|
|
->assertJsonPath('data.0.expense', 10000)
|
|
->assertJsonPath('data.0.net', 10000);
|
|
|
|
$sankey->assertOk()
|
|
->assertJsonPath('total_income', 20000)
|
|
->assertJsonPath('total_expense', 10000)
|
|
->assertJsonPath('income_categories.0.amount', 20000)
|
|
->assertJsonPath('expense_categories.0.amount', 10000);
|
|
|
|
$incomeBreakdown->assertOk()
|
|
->assertJsonPath('total', 20000)
|
|
->assertJsonPath('data.0.amount', 20000)
|
|
->assertJsonPath('data.0.percentage', 100);
|
|
|
|
$expenseBreakdown->assertOk()
|
|
->assertJsonPath('total', 10000)
|
|
->assertJsonPath('data.0.amount', 10000)
|
|
->assertJsonPath('data.0.percentage', 100);
|
|
|
|
Http::assertNothingSent();
|
|
});
|
|
|
|
test('cashflow summary includes actual saved and invested amounts', function () {
|
|
$incomeCategory = Category::factory()->create([
|
|
'user_id' => $this->user->id,
|
|
'type' => CategoryType::Income,
|
|
]);
|
|
$expenseCategory = Category::factory()->create([
|
|
'user_id' => $this->user->id,
|
|
'type' => CategoryType::Expense,
|
|
]);
|
|
$savingsCategory = Category::factory()->create([
|
|
'user_id' => $this->user->id,
|
|
'type' => CategoryType::Savings,
|
|
]);
|
|
$investmentCategory = Category::factory()->create([
|
|
'user_id' => $this->user->id,
|
|
'type' => CategoryType::Investment,
|
|
]);
|
|
|
|
$account = Account::factory()->create(['user_id' => $this->user->id]);
|
|
|
|
Transaction::factory()->create([
|
|
'user_id' => $this->user->id,
|
|
'account_id' => $account->id,
|
|
'category_id' => $incomeCategory->id,
|
|
'amount' => 100000,
|
|
'transaction_date' => now(),
|
|
]);
|
|
Transaction::factory()->create([
|
|
'user_id' => $this->user->id,
|
|
'account_id' => $account->id,
|
|
'category_id' => $expenseCategory->id,
|
|
'amount' => -40000,
|
|
'transaction_date' => now(),
|
|
]);
|
|
Transaction::factory()->create([
|
|
'user_id' => $this->user->id,
|
|
'account_id' => $account->id,
|
|
'category_id' => $savingsCategory->id,
|
|
'amount' => -25000,
|
|
'transaction_date' => now(),
|
|
]);
|
|
Transaction::factory()->create([
|
|
'user_id' => $this->user->id,
|
|
'account_id' => $account->id,
|
|
'category_id' => $savingsCategory->id,
|
|
'amount' => 5000,
|
|
'transaction_date' => now(),
|
|
]);
|
|
Transaction::factory()->create([
|
|
'user_id' => $this->user->id,
|
|
'account_id' => $account->id,
|
|
'category_id' => $investmentCategory->id,
|
|
'amount' => -15000,
|
|
'transaction_date' => now(),
|
|
]);
|
|
|
|
$response = $this->getJson('/api/cashflow/summary?'.http_build_query([
|
|
'from' => now()->startOfMonth()->toDateString(),
|
|
'to' => now()->endOfMonth()->toDateString(),
|
|
]));
|
|
|
|
$response->assertOk()
|
|
->assertJsonPath('current.income', 100000)
|
|
->assertJsonPath('current.expense', 40000)
|
|
->assertJsonPath('current.net', 60000)
|
|
->assertJsonPath('current.savings_rate', 60)
|
|
->assertJsonPath('current.savings', 25000)
|
|
->assertJsonPath('current.investments', 15000);
|
|
});
|
|
|
|
test('cashflow summary compares full quarter against previous full quarter', function () {
|
|
$incomeCategory = Category::factory()->create([
|
|
'user_id' => $this->user->id,
|
|
'type' => CategoryType::Income,
|
|
]);
|
|
|
|
$account = Account::factory()->create(['user_id' => $this->user->id]);
|
|
|
|
Transaction::factory()->create([
|
|
'user_id' => $this->user->id,
|
|
'account_id' => $account->id,
|
|
'category_id' => $incomeCategory->id,
|
|
'amount' => 100000,
|
|
'transaction_date' => '2025-04-15',
|
|
]);
|
|
Transaction::factory()->create([
|
|
'user_id' => $this->user->id,
|
|
'account_id' => $account->id,
|
|
'category_id' => $incomeCategory->id,
|
|
'amount' => 30000,
|
|
'transaction_date' => '2025-01-15',
|
|
]);
|
|
Transaction::factory()->create([
|
|
'user_id' => $this->user->id,
|
|
'account_id' => $account->id,
|
|
'category_id' => $incomeCategory->id,
|
|
'amount' => 40000,
|
|
'transaction_date' => '2025-02-15',
|
|
]);
|
|
Transaction::factory()->create([
|
|
'user_id' => $this->user->id,
|
|
'account_id' => $account->id,
|
|
'category_id' => $incomeCategory->id,
|
|
'amount' => 50000,
|
|
'transaction_date' => '2025-03-15',
|
|
]);
|
|
|
|
$response = $this->getJson('/api/cashflow/summary?'.http_build_query([
|
|
'from' => '2025-04-01',
|
|
'to' => '2025-06-30',
|
|
]));
|
|
|
|
$response->assertOk()
|
|
->assertJsonPath('current.income', 100000)
|
|
->assertJsonPath('previous.income', 120000);
|
|
});
|
|
|
|
test('cashflow summary handles zero income for savings rate', function () {
|
|
$expenseCategory = Category::factory()->create([
|
|
'user_id' => $this->user->id,
|
|
'type' => CategoryType::Expense,
|
|
]);
|
|
|
|
$account = Account::factory()->create(['user_id' => $this->user->id]);
|
|
|
|
// Only expense, no income
|
|
Transaction::factory()->create([
|
|
'user_id' => $this->user->id,
|
|
'account_id' => $account->id,
|
|
'category_id' => $expenseCategory->id,
|
|
'amount' => -10000,
|
|
'transaction_date' => now(),
|
|
]);
|
|
|
|
$response = $this->getJson('/api/cashflow/summary?'.http_build_query([
|
|
'from' => now()->startOfMonth()->toDateString(),
|
|
'to' => now()->endOfMonth()->toDateString(),
|
|
]));
|
|
|
|
$response->assertOk()
|
|
->assertJson([
|
|
'current' => [
|
|
'income' => 0,
|
|
'expense' => 10000,
|
|
'net' => -10000,
|
|
'savings_rate' => 0, // Avoid division by zero
|
|
],
|
|
]);
|
|
});
|
|
|
|
test('cashflow summary returns previous period data', function () {
|
|
$incomeCategory = Category::factory()->create([
|
|
'user_id' => $this->user->id,
|
|
'type' => CategoryType::Income,
|
|
]);
|
|
|
|
$account = Account::factory()->create(['user_id' => $this->user->id]);
|
|
|
|
// Current period income
|
|
Transaction::factory()->create([
|
|
'user_id' => $this->user->id,
|
|
'account_id' => $account->id,
|
|
'category_id' => $incomeCategory->id,
|
|
'amount' => 50000,
|
|
'transaction_date' => now(),
|
|
]);
|
|
|
|
// Previous period income
|
|
Transaction::factory()->create([
|
|
'user_id' => $this->user->id,
|
|
'account_id' => $account->id,
|
|
'category_id' => $incomeCategory->id,
|
|
'amount' => 30000,
|
|
'transaction_date' => now()->subMonthNoOverflow(),
|
|
]);
|
|
|
|
$response = $this->getJson('/api/cashflow/summary?'.http_build_query([
|
|
'from' => now()->startOfMonth()->toDateString(),
|
|
'to' => now()->endOfMonth()->toDateString(),
|
|
]));
|
|
|
|
$response->assertOk();
|
|
$data = $response->json();
|
|
|
|
expect($data['current']['income'])->toBe(50000);
|
|
expect($data['previous']['income'])->toBe(30000);
|
|
});
|
|
|
|
test('sankey returns income and expense categories with amounts', function () {
|
|
$incomeCategory1 = Category::factory()->create([
|
|
'user_id' => $this->user->id,
|
|
'type' => CategoryType::Income,
|
|
'name' => 'Salary',
|
|
]);
|
|
$incomeCategory2 = Category::factory()->create([
|
|
'user_id' => $this->user->id,
|
|
'type' => CategoryType::Income,
|
|
'name' => 'Freelance',
|
|
]);
|
|
$expenseCategory1 = Category::factory()->create([
|
|
'user_id' => $this->user->id,
|
|
'type' => CategoryType::Expense,
|
|
'name' => 'Housing',
|
|
]);
|
|
$expenseCategory2 = Category::factory()->create([
|
|
'user_id' => $this->user->id,
|
|
'type' => CategoryType::Expense,
|
|
'name' => 'Food',
|
|
]);
|
|
|
|
$account = Account::factory()->create(['user_id' => $this->user->id]);
|
|
|
|
// Income transactions
|
|
Transaction::factory()->create([
|
|
'user_id' => $this->user->id,
|
|
'account_id' => $account->id,
|
|
'category_id' => $incomeCategory1->id,
|
|
'amount' => 500000, // $5000
|
|
'transaction_date' => now(),
|
|
]);
|
|
Transaction::factory()->create([
|
|
'user_id' => $this->user->id,
|
|
'account_id' => $account->id,
|
|
'category_id' => $incomeCategory2->id,
|
|
'amount' => 100000, // $1000
|
|
'transaction_date' => now(),
|
|
]);
|
|
|
|
// Expense transactions
|
|
Transaction::factory()->create([
|
|
'user_id' => $this->user->id,
|
|
'account_id' => $account->id,
|
|
'category_id' => $expenseCategory1->id,
|
|
'amount' => -150000, // $1500
|
|
'transaction_date' => now(),
|
|
]);
|
|
Transaction::factory()->create([
|
|
'user_id' => $this->user->id,
|
|
'account_id' => $account->id,
|
|
'category_id' => $expenseCategory2->id,
|
|
'amount' => -50000, // $500
|
|
'transaction_date' => now(),
|
|
]);
|
|
|
|
$response = $this->getJson('/api/cashflow/sankey?'.http_build_query([
|
|
'from' => now()->startOfMonth()->toDateString(),
|
|
'to' => now()->endOfMonth()->toDateString(),
|
|
]));
|
|
|
|
$response->assertOk();
|
|
$data = $response->json();
|
|
|
|
expect($data)->toHaveKeys(['income_categories', 'expense_categories', 'total_income', 'total_expense']);
|
|
expect($data['total_income'])->toBe(600000);
|
|
expect($data['total_expense'])->toBe(200000);
|
|
expect($data['income_categories'])->toHaveCount(2);
|
|
expect($data['expense_categories'])->toHaveCount(2);
|
|
|
|
// Check that categories include required data
|
|
$incomeIds = collect($data['income_categories'])->pluck('category.id')->toArray();
|
|
expect($incomeIds)->toContain($incomeCategory1->id);
|
|
expect($incomeIds)->toContain($incomeCategory2->id);
|
|
});
|
|
|
|
test('cashflow trend returns monthly data for specified months', function () {
|
|
$incomeCategory = Category::factory()->create([
|
|
'user_id' => $this->user->id,
|
|
'type' => CategoryType::Income,
|
|
]);
|
|
$expenseCategory = Category::factory()->create([
|
|
'user_id' => $this->user->id,
|
|
'type' => CategoryType::Expense,
|
|
]);
|
|
|
|
$account = Account::factory()->create(['user_id' => $this->user->id]);
|
|
|
|
// Create transactions for 3 months
|
|
for ($i = 0; $i < 3; $i++) {
|
|
Transaction::factory()->create([
|
|
'user_id' => $this->user->id,
|
|
'account_id' => $account->id,
|
|
'category_id' => $incomeCategory->id,
|
|
'amount' => 100000 + ($i * 10000),
|
|
'transaction_date' => now()->subMonthsNoOverflow($i),
|
|
]);
|
|
Transaction::factory()->create([
|
|
'user_id' => $this->user->id,
|
|
'account_id' => $account->id,
|
|
'category_id' => $expenseCategory->id,
|
|
'amount' => -(50000 + ($i * 5000)),
|
|
'transaction_date' => now()->subMonthsNoOverflow($i),
|
|
]);
|
|
}
|
|
|
|
$response = $this->getJson('/api/cashflow/trend?months=3');
|
|
|
|
$response->assertOk();
|
|
$data = $response->json();
|
|
|
|
expect($data['data'])->toHaveCount(3);
|
|
expect($data['data'][0])->toHaveKeys(['month', 'income', 'expense', 'net']);
|
|
});
|
|
|
|
test('cashflow analytics net refunds in expense categories', function () {
|
|
$foodDelivery = Category::factory()->create([
|
|
'user_id' => $this->user->id,
|
|
'type' => CategoryType::Expense,
|
|
'name' => 'Food Delivery',
|
|
]);
|
|
|
|
$account = Account::factory()->create(['user_id' => $this->user->id]);
|
|
|
|
Transaction::factory()->create([
|
|
'user_id' => $this->user->id,
|
|
'account_id' => $account->id,
|
|
'category_id' => $foodDelivery->id,
|
|
'amount' => -8000,
|
|
'transaction_date' => '2026-05-05',
|
|
]);
|
|
Transaction::factory()->create([
|
|
'user_id' => $this->user->id,
|
|
'account_id' => $account->id,
|
|
'category_id' => $foodDelivery->id,
|
|
'amount' => 2000,
|
|
'transaction_date' => '2026-05-06',
|
|
]);
|
|
Transaction::factory()->create([
|
|
'user_id' => $this->user->id,
|
|
'account_id' => $account->id,
|
|
'category_id' => $foodDelivery->id,
|
|
'amount' => 2000,
|
|
'transaction_date' => '2026-05-07',
|
|
]);
|
|
|
|
$summary = $this->getJson('/api/cashflow/summary?'.http_build_query([
|
|
'from' => '2026-05-01',
|
|
'to' => '2026-05-31',
|
|
]));
|
|
$trend = $this->getJson('/api/cashflow/trend?'.http_build_query([
|
|
'from' => '2026-05-01',
|
|
'to' => '2026-05-31',
|
|
]));
|
|
$breakdown = $this->getJson('/api/cashflow/breakdown?'.http_build_query([
|
|
'from' => '2026-05-01',
|
|
'to' => '2026-05-31',
|
|
'type' => 'expense',
|
|
]));
|
|
$sankey = $this->getJson('/api/cashflow/sankey?'.http_build_query([
|
|
'from' => '2026-05-01',
|
|
'to' => '2026-05-31',
|
|
]));
|
|
|
|
$summary->assertOk()
|
|
->assertJsonPath('current.expense', 4000)
|
|
->assertJsonPath('current.net', -4000);
|
|
|
|
$trend->assertOk()
|
|
->assertJsonPath('data.0.expense', 4000)
|
|
->assertJsonPath('data.0.net', -4000);
|
|
|
|
$breakdown->assertOk()
|
|
->assertJsonPath('total', 4000)
|
|
->assertJsonPath('data.0.category.name', 'Food Delivery')
|
|
->assertJsonPath('data.0.amount', 4000);
|
|
|
|
$sankey->assertOk()
|
|
->assertJsonPath('total_income', 0)
|
|
->assertJsonPath('total_expense', 4000)
|
|
->assertJsonPath('expense_categories.0.category.name', 'Food Delivery')
|
|
->assertJsonPath('expense_categories.0.amount', 4000);
|
|
});
|
|
|
|
test('cashflow trend does not include tracked transfers', function () {
|
|
$incomeCategory = Category::factory()->create([
|
|
'user_id' => $this->user->id,
|
|
'type' => CategoryType::Income,
|
|
]);
|
|
$expenseCategory = Category::factory()->create([
|
|
'user_id' => $this->user->id,
|
|
'type' => CategoryType::Expense,
|
|
]);
|
|
$investmentCategory = Category::factory()->create([
|
|
'user_id' => $this->user->id,
|
|
'type' => CategoryType::Transfer,
|
|
'cashflow_direction' => CategoryCashflowDirection::Outflow,
|
|
'name' => 'Investment',
|
|
]);
|
|
$rebalanceCategory = Category::factory()->create([
|
|
'user_id' => $this->user->id,
|
|
'type' => CategoryType::Transfer,
|
|
'cashflow_direction' => CategoryCashflowDirection::Inflow,
|
|
'name' => 'Investment Return Transfer',
|
|
]);
|
|
|
|
$account = Account::factory()->create(['user_id' => $this->user->id]);
|
|
|
|
Transaction::factory()->create([
|
|
'user_id' => $this->user->id,
|
|
'account_id' => $account->id,
|
|
'category_id' => $incomeCategory->id,
|
|
'amount' => 100000,
|
|
'transaction_date' => now(),
|
|
]);
|
|
Transaction::factory()->create([
|
|
'user_id' => $this->user->id,
|
|
'account_id' => $account->id,
|
|
'category_id' => $expenseCategory->id,
|
|
'amount' => -40000,
|
|
'transaction_date' => now(),
|
|
]);
|
|
Transaction::factory()->create([
|
|
'user_id' => $this->user->id,
|
|
'account_id' => $account->id,
|
|
'category_id' => $investmentCategory->id,
|
|
'amount' => -25000,
|
|
'transaction_date' => now(),
|
|
]);
|
|
Transaction::factory()->create([
|
|
'user_id' => $this->user->id,
|
|
'account_id' => $account->id,
|
|
'category_id' => $rebalanceCategory->id,
|
|
'amount' => 15000,
|
|
'transaction_date' => now(),
|
|
]);
|
|
|
|
$response = $this->getJson('/api/cashflow/trend?months=1');
|
|
|
|
$response->assertOk();
|
|
$point = $response->json('data.0');
|
|
|
|
// Only income/expense categories affect the trend — transfers are excluded
|
|
expect($point['income'])->toBe(100000);
|
|
expect($point['expense'])->toBe(40000);
|
|
expect($point['net'])->toBe(60000);
|
|
expect($point)->not->toHaveKey('transfer_outflow');
|
|
expect($point)->not->toHaveKey('transfer_inflow');
|
|
});
|
|
|
|
test('cashflow trend anchors the 12-month series to the requested period end month', function () {
|
|
$incomeCategory = Category::factory()->create([
|
|
'user_id' => $this->user->id,
|
|
'type' => CategoryType::Income,
|
|
'name' => 'Salary',
|
|
]);
|
|
|
|
$account = Account::factory()->create(['user_id' => $this->user->id]);
|
|
|
|
Transaction::factory()->create([
|
|
'user_id' => $this->user->id,
|
|
'account_id' => $account->id,
|
|
'category_id' => $incomeCategory->id,
|
|
'amount' => 32000,
|
|
'transaction_date' => '2025-04-14',
|
|
]);
|
|
|
|
Transaction::factory()->create([
|
|
'user_id' => $this->user->id,
|
|
'account_id' => $account->id,
|
|
'category_id' => $incomeCategory->id,
|
|
'amount' => 48000,
|
|
'transaction_date' => '2025-05-07',
|
|
]);
|
|
|
|
$response = $this->getJson('/api/cashflow/trend?months=12&to=2025-05-31');
|
|
|
|
$response->assertOk();
|
|
|
|
$data = collect($response->json('data'))->keyBy('month');
|
|
|
|
expect($data)->toHaveCount(12);
|
|
expect($data->keys()->first())->toBe('2024-06');
|
|
expect($data->keys()->last())->toBe('2025-05');
|
|
expect($data['2025-04']['income'])->toBe(32000);
|
|
expect($data['2025-05']['income'])->toBe(48000);
|
|
});
|
|
|
|
test('cashflow trend can use explicit period bounds', function () {
|
|
$incomeCategory = Category::factory()->create([
|
|
'user_id' => $this->user->id,
|
|
'type' => CategoryType::Income,
|
|
]);
|
|
|
|
$account = Account::factory()->create(['user_id' => $this->user->id]);
|
|
|
|
Transaction::factory()->create([
|
|
'user_id' => $this->user->id,
|
|
'account_id' => $account->id,
|
|
'category_id' => $incomeCategory->id,
|
|
'amount' => 32000,
|
|
'transaction_date' => '2025-04-14',
|
|
]);
|
|
Transaction::factory()->create([
|
|
'user_id' => $this->user->id,
|
|
'account_id' => $account->id,
|
|
'category_id' => $incomeCategory->id,
|
|
'amount' => 48000,
|
|
'transaction_date' => '2025-05-07',
|
|
]);
|
|
|
|
$response = $this->getJson('/api/cashflow/trend?'.http_build_query([
|
|
'from' => '2025-04-01',
|
|
'to' => '2025-06-30',
|
|
]));
|
|
|
|
$response->assertOk();
|
|
|
|
$data = collect($response->json('data'))->keyBy('month');
|
|
|
|
expect($data)->toHaveCount(3);
|
|
expect($data->keys()->all())->toBe(['2025-04', '2025-05', '2025-06']);
|
|
expect($data['2025-04']['income'])->toBe(32000);
|
|
expect($data['2025-05']['income'])->toBe(48000);
|
|
});
|
|
|
|
test('cashflow trend caps the window for unbounded date ranges', function () {
|
|
$response = $this->getJson('/api/cashflow/trend?'.http_build_query([
|
|
'from' => '0001-01-01',
|
|
'to' => '9999-12-31',
|
|
]));
|
|
|
|
$response->assertOk();
|
|
expect(count($response->json('data')))->toBe(24);
|
|
});
|
|
|
|
test('cashflow trend defaults to 12 months', function () {
|
|
$response = $this->getJson('/api/cashflow/trend');
|
|
|
|
$response->assertOk();
|
|
$data = $response->json();
|
|
|
|
expect($data['data'])->toHaveCount(12);
|
|
});
|
|
|
|
test('breakdown returns category amounts with percentages for expenses', function () {
|
|
$cat1 = Category::factory()->create([
|
|
'user_id' => $this->user->id,
|
|
'type' => CategoryType::Expense,
|
|
'name' => 'Housing',
|
|
]);
|
|
$cat2 = Category::factory()->create([
|
|
'user_id' => $this->user->id,
|
|
'type' => CategoryType::Expense,
|
|
'name' => 'Food',
|
|
]);
|
|
|
|
$account = Account::factory()->create(['user_id' => $this->user->id]);
|
|
|
|
// Housing: 60% of total
|
|
Transaction::factory()->create([
|
|
'user_id' => $this->user->id,
|
|
'account_id' => $account->id,
|
|
'category_id' => $cat1->id,
|
|
'amount' => -60000,
|
|
'transaction_date' => now(),
|
|
]);
|
|
|
|
// Food: 40% of total
|
|
Transaction::factory()->create([
|
|
'user_id' => $this->user->id,
|
|
'account_id' => $account->id,
|
|
'category_id' => $cat2->id,
|
|
'amount' => -40000,
|
|
'transaction_date' => now(),
|
|
]);
|
|
|
|
$response = $this->getJson('/api/cashflow/breakdown?'.http_build_query([
|
|
'from' => now()->startOfMonth()->toDateString(),
|
|
'to' => now()->endOfMonth()->toDateString(),
|
|
'type' => 'expense',
|
|
]));
|
|
|
|
$response->assertOk();
|
|
$data = $response->json();
|
|
|
|
expect($data)->toHaveKeys(['data', 'total', 'previous_total']);
|
|
expect($data['total'])->toBe(100000);
|
|
expect($data['data'])->toHaveCount(2);
|
|
|
|
// Should be sorted by amount desc (Housing first)
|
|
expect($data['data'][0]['category']['name'])->toBe('Housing');
|
|
expect($data['data'][0]['amount'])->toBe(60000);
|
|
expect($data['data'][0]['percentage'])->toEqual(60.0);
|
|
expect($data['data'][0])->toHaveKey('previous_amount');
|
|
});
|
|
|
|
test('breakdown returns category amounts for income', function () {
|
|
$cat1 = Category::factory()->create([
|
|
'user_id' => $this->user->id,
|
|
'type' => CategoryType::Income,
|
|
'name' => 'Salary',
|
|
]);
|
|
$cat2 = Category::factory()->create([
|
|
'user_id' => $this->user->id,
|
|
'type' => CategoryType::Income,
|
|
'name' => 'Freelance',
|
|
]);
|
|
|
|
$account = Account::factory()->create(['user_id' => $this->user->id]);
|
|
|
|
Transaction::factory()->create([
|
|
'user_id' => $this->user->id,
|
|
'account_id' => $account->id,
|
|
'category_id' => $cat1->id,
|
|
'amount' => 500000,
|
|
'transaction_date' => now(),
|
|
]);
|
|
|
|
Transaction::factory()->create([
|
|
'user_id' => $this->user->id,
|
|
'account_id' => $account->id,
|
|
'category_id' => $cat2->id,
|
|
'amount' => 100000,
|
|
'transaction_date' => now(),
|
|
]);
|
|
|
|
$response = $this->getJson('/api/cashflow/breakdown?'.http_build_query([
|
|
'from' => now()->startOfMonth()->toDateString(),
|
|
'to' => now()->endOfMonth()->toDateString(),
|
|
'type' => 'income',
|
|
]));
|
|
|
|
$response->assertOk();
|
|
$data = $response->json();
|
|
|
|
expect($data['total'])->toBe(600000);
|
|
expect($data['data'])->toHaveCount(2);
|
|
expect($data['data'][0]['category']['name'])->toBe('Salary');
|
|
});
|
|
|
|
test('breakdown requires type parameter', function () {
|
|
$response = $this->getJson('/api/cashflow/breakdown?'.http_build_query([
|
|
'from' => now()->startOfMonth()->toDateString(),
|
|
'to' => now()->endOfMonth()->toDateString(),
|
|
]));
|
|
|
|
$response->assertUnprocessable();
|
|
});
|
|
|
|
test('cashflow page is accessible', function () {
|
|
$user = User::factory()->onboarded()->create();
|
|
$this->actingAs($user);
|
|
|
|
$response = $this->get('/cashflow');
|
|
|
|
$response->assertOk();
|
|
$response->assertInertia(fn ($page) => $page->component('cashflow/index'));
|
|
});
|
|
|
|
test('cashflow page returns categories with type', function () {
|
|
$user = User::factory()->onboarded()->create();
|
|
$this->actingAs($user);
|
|
|
|
Category::factory()->create([
|
|
'user_id' => $user->id,
|
|
'type' => CategoryType::Income,
|
|
'name' => 'Salary',
|
|
]);
|
|
Category::factory()->create([
|
|
'user_id' => $user->id,
|
|
'type' => CategoryType::Expense,
|
|
'name' => 'Food',
|
|
]);
|
|
|
|
$response = $this->get('/cashflow');
|
|
|
|
$response->assertOk();
|
|
$response->assertInertia(fn ($page) => $page
|
|
->component('cashflow/index')
|
|
->has('categories', 2)
|
|
->has('categories.0.type')
|
|
->has('categories.0.cashflow_direction')
|
|
);
|
|
});
|
|
|
|
test('cashflow summary includes uncategorized income', function () {
|
|
$account = Account::factory()->create(['user_id' => $this->user->id]);
|
|
|
|
// Uncategorized positive amount = income
|
|
Transaction::factory()->create([
|
|
'user_id' => $this->user->id,
|
|
'account_id' => $account->id,
|
|
'category_id' => null,
|
|
'amount' => 50000, // $500
|
|
'transaction_date' => now(),
|
|
]);
|
|
|
|
$response = $this->getJson('/api/cashflow/summary?'.http_build_query([
|
|
'from' => now()->startOfMonth()->toDateString(),
|
|
'to' => now()->endOfMonth()->toDateString(),
|
|
]));
|
|
|
|
$response->assertOk()
|
|
->assertJson([
|
|
'current' => [
|
|
'income' => 50000,
|
|
'expense' => 0,
|
|
'net' => 50000,
|
|
],
|
|
]);
|
|
});
|
|
|
|
test('cashflow summary includes uncategorized expenses', function () {
|
|
$account = Account::factory()->create(['user_id' => $this->user->id]);
|
|
|
|
// Uncategorized negative amount = expense
|
|
Transaction::factory()->create([
|
|
'user_id' => $this->user->id,
|
|
'account_id' => $account->id,
|
|
'category_id' => null,
|
|
'amount' => -30000, // $300
|
|
'transaction_date' => now(),
|
|
]);
|
|
|
|
$response = $this->getJson('/api/cashflow/summary?'.http_build_query([
|
|
'from' => now()->startOfMonth()->toDateString(),
|
|
'to' => now()->endOfMonth()->toDateString(),
|
|
]));
|
|
|
|
$response->assertOk()
|
|
->assertJson([
|
|
'current' => [
|
|
'income' => 0,
|
|
'expense' => 30000,
|
|
'net' => -30000,
|
|
],
|
|
]);
|
|
});
|
|
|
|
test('sankey includes unknown income and expense categories', function () {
|
|
$account = Account::factory()->create(['user_id' => $this->user->id]);
|
|
|
|
// Uncategorized income
|
|
Transaction::factory()->create([
|
|
'user_id' => $this->user->id,
|
|
'account_id' => $account->id,
|
|
'category_id' => null,
|
|
'amount' => 100000,
|
|
'transaction_date' => now(),
|
|
]);
|
|
|
|
// Uncategorized expense
|
|
Transaction::factory()->create([
|
|
'user_id' => $this->user->id,
|
|
'account_id' => $account->id,
|
|
'category_id' => null,
|
|
'amount' => -50000,
|
|
'transaction_date' => now(),
|
|
]);
|
|
|
|
$response = $this->getJson('/api/cashflow/sankey?'.http_build_query([
|
|
'from' => now()->startOfMonth()->toDateString(),
|
|
'to' => now()->endOfMonth()->toDateString(),
|
|
]));
|
|
|
|
$response->assertOk();
|
|
$data = $response->json();
|
|
|
|
expect($data['income_categories'])->toHaveCount(1);
|
|
expect($data['expense_categories'])->toHaveCount(1);
|
|
expect($data['income_categories'][0]['category']['name'])->toBe('Unknown Income');
|
|
expect($data['income_categories'][0]['amount'])->toBe(100000);
|
|
expect($data['expense_categories'][0]['category']['name'])->toBe('Unknown Expense');
|
|
expect($data['expense_categories'][0]['amount'])->toBe(50000);
|
|
});
|
|
|
|
test('sankey income category nets mixed positive and negative transactions', function () {
|
|
$incomeFromRents = Category::factory()->create([
|
|
'user_id' => $this->user->id,
|
|
'type' => CategoryType::Income,
|
|
'name' => 'Income from Rents',
|
|
]);
|
|
|
|
$account = Account::factory()->create(['user_id' => $this->user->id]);
|
|
|
|
Transaction::factory()->create([
|
|
'user_id' => $this->user->id,
|
|
'account_id' => $account->id,
|
|
'category_id' => $incomeFromRents->id,
|
|
'amount' => 30000,
|
|
'transaction_date' => '2026-03-10',
|
|
]);
|
|
Transaction::factory()->create([
|
|
'user_id' => $this->user->id,
|
|
'account_id' => $account->id,
|
|
'category_id' => $incomeFromRents->id,
|
|
'amount' => -10000,
|
|
'transaction_date' => '2026-03-13',
|
|
]);
|
|
|
|
$response = $this->getJson('/api/cashflow/sankey?'.http_build_query([
|
|
'from' => '2026-03-01',
|
|
'to' => '2026-03-31',
|
|
]));
|
|
|
|
$response->assertOk();
|
|
$data = $response->json();
|
|
|
|
$rentIncome = collect($data['income_categories'])->firstWhere('category.name', 'Income from Rents');
|
|
expect($rentIncome)->not->toBeNull();
|
|
expect($rentIncome['amount'])->toBe(20000);
|
|
expect($data['total_income'])->toBe(20000);
|
|
expect(collect($data['expense_categories'])->pluck('category.name'))->not->toContain('Income from Rents');
|
|
expect($data['total_expense'])->toBe(0);
|
|
});
|
|
|
|
test('sankey excludes hidden transfer categories from both sides', function () {
|
|
$incomeCategory = Category::factory()->create([
|
|
'user_id' => $this->user->id,
|
|
'type' => CategoryType::Income,
|
|
'name' => 'Salary',
|
|
]);
|
|
$expenseCategory = Category::factory()->create([
|
|
'user_id' => $this->user->id,
|
|
'type' => CategoryType::Expense,
|
|
'name' => 'Groceries',
|
|
]);
|
|
$hiddenTransferCategory = Category::factory()->create([
|
|
'user_id' => $this->user->id,
|
|
'type' => CategoryType::Transfer,
|
|
'cashflow_direction' => CategoryCashflowDirection::Hidden,
|
|
'name' => 'Internal Transfer',
|
|
]);
|
|
|
|
$account = Account::factory()->create(['user_id' => $this->user->id]);
|
|
|
|
Transaction::factory()->create([
|
|
'user_id' => $this->user->id,
|
|
'account_id' => $account->id,
|
|
'category_id' => $incomeCategory->id,
|
|
'amount' => 200000,
|
|
'transaction_date' => now(),
|
|
]);
|
|
Transaction::factory()->create([
|
|
'user_id' => $this->user->id,
|
|
'account_id' => $account->id,
|
|
'category_id' => $expenseCategory->id,
|
|
'amount' => -50000,
|
|
'transaction_date' => now(),
|
|
]);
|
|
|
|
Transaction::factory()->create([
|
|
'user_id' => $this->user->id,
|
|
'account_id' => $account->id,
|
|
'category_id' => $hiddenTransferCategory->id,
|
|
'amount' => 100000,
|
|
'transaction_date' => now(),
|
|
]);
|
|
Transaction::factory()->create([
|
|
'user_id' => $this->user->id,
|
|
'account_id' => $account->id,
|
|
'category_id' => $hiddenTransferCategory->id,
|
|
'amount' => -75000,
|
|
'transaction_date' => now(),
|
|
]);
|
|
|
|
$response = $this->getJson('/api/cashflow/sankey?'.http_build_query([
|
|
'from' => now()->startOfMonth()->toDateString(),
|
|
'to' => now()->endOfMonth()->toDateString(),
|
|
]));
|
|
|
|
$response->assertOk();
|
|
$data = $response->json();
|
|
|
|
expect($data['total_income'])->toBe(200000);
|
|
expect($data['total_expense'])->toBe(50000);
|
|
expect(collect($data['income_categories'])->pluck('category.name'))->not->toContain('Internal Transfer');
|
|
expect(collect($data['expense_categories'])->pluck('category.name'))->not->toContain('Internal Transfer');
|
|
});
|
|
|
|
test('sankey includes outflow transfer categories on the expense side', function () {
|
|
$incomeCategory = Category::factory()->create([
|
|
'user_id' => $this->user->id,
|
|
'type' => CategoryType::Income,
|
|
'name' => 'Salary',
|
|
]);
|
|
$investmentCategory = Category::factory()->create([
|
|
'user_id' => $this->user->id,
|
|
'type' => CategoryType::Transfer,
|
|
'cashflow_direction' => CategoryCashflowDirection::Outflow,
|
|
'name' => 'Investments',
|
|
]);
|
|
|
|
$account = Account::factory()->create(['user_id' => $this->user->id]);
|
|
|
|
Transaction::factory()->create([
|
|
'user_id' => $this->user->id,
|
|
'account_id' => $account->id,
|
|
'category_id' => $incomeCategory->id,
|
|
'amount' => 300000,
|
|
'transaction_date' => now(),
|
|
]);
|
|
Transaction::factory()->create([
|
|
'user_id' => $this->user->id,
|
|
'account_id' => $account->id,
|
|
'category_id' => $investmentCategory->id,
|
|
'amount' => -50000,
|
|
'transaction_date' => now(),
|
|
]);
|
|
|
|
$response = $this->getJson('/api/cashflow/sankey?'.http_build_query([
|
|
'from' => now()->startOfMonth()->toDateString(),
|
|
'to' => now()->endOfMonth()->toDateString(),
|
|
]));
|
|
|
|
$response->assertOk();
|
|
$data = $response->json();
|
|
|
|
expect($data['total_income'])->toBe(300000);
|
|
expect($data['total_expense'])->toBe(50000);
|
|
|
|
$investmentExpense = collect($data['expense_categories'])->firstWhere('category.name', 'Investments');
|
|
expect($investmentExpense)->not->toBeNull();
|
|
expect($investmentExpense['amount'])->toBe(50000);
|
|
|
|
// Outflow transfers should not appear on the income side
|
|
expect(collect($data['income_categories'])->pluck('category.name'))->not->toContain('Investments');
|
|
});
|
|
|
|
test('sankey includes savings and investment categories on the expense side', function () {
|
|
$incomeCategory = Category::factory()->create([
|
|
'user_id' => $this->user->id,
|
|
'type' => CategoryType::Income,
|
|
'name' => 'Salary',
|
|
]);
|
|
$savingsCategory = Category::factory()->create([
|
|
'user_id' => $this->user->id,
|
|
'type' => CategoryType::Savings,
|
|
'cashflow_direction' => CategoryCashflowDirection::Hidden,
|
|
'name' => 'Emergency Savings',
|
|
]);
|
|
$investmentCategory = Category::factory()->create([
|
|
'user_id' => $this->user->id,
|
|
'type' => CategoryType::Investment,
|
|
'cashflow_direction' => CategoryCashflowDirection::Hidden,
|
|
'name' => 'Brokerage',
|
|
]);
|
|
|
|
$account = Account::factory()->create(['user_id' => $this->user->id]);
|
|
|
|
Transaction::factory()->create([
|
|
'user_id' => $this->user->id,
|
|
'account_id' => $account->id,
|
|
'category_id' => $incomeCategory->id,
|
|
'amount' => 300000,
|
|
'transaction_date' => now(),
|
|
]);
|
|
Transaction::factory()->create([
|
|
'user_id' => $this->user->id,
|
|
'account_id' => $account->id,
|
|
'category_id' => $savingsCategory->id,
|
|
'amount' => -40000,
|
|
'transaction_date' => now(),
|
|
]);
|
|
Transaction::factory()->create([
|
|
'user_id' => $this->user->id,
|
|
'account_id' => $account->id,
|
|
'category_id' => $investmentCategory->id,
|
|
'amount' => -60000,
|
|
'transaction_date' => now(),
|
|
]);
|
|
|
|
$response = $this->getJson('/api/cashflow/sankey?'.http_build_query([
|
|
'from' => now()->startOfMonth()->toDateString(),
|
|
'to' => now()->endOfMonth()->toDateString(),
|
|
]));
|
|
|
|
$response->assertOk();
|
|
$data = $response->json();
|
|
|
|
expect($data['total_income'])->toBe(300000);
|
|
expect($data['total_expense'])->toBe(100000);
|
|
|
|
$savingsExpense = collect($data['expense_categories'])->firstWhere('category.name', 'Emergency Savings');
|
|
expect($savingsExpense)->not->toBeNull();
|
|
expect($savingsExpense['amount'])->toBe(40000);
|
|
|
|
$investmentExpense = collect($data['expense_categories'])->firstWhere('category.name', 'Brokerage');
|
|
expect($investmentExpense)->not->toBeNull();
|
|
expect($investmentExpense['amount'])->toBe(60000);
|
|
|
|
expect(collect($data['income_categories'])->pluck('category.name'))
|
|
->not->toContain('Emergency Savings')
|
|
->not->toContain('Brokerage');
|
|
});
|
|
|
|
test('sankey includes inflow transfer categories on the income side', function () {
|
|
$expenseCategory = Category::factory()->create([
|
|
'user_id' => $this->user->id,
|
|
'type' => CategoryType::Expense,
|
|
'name' => 'Groceries',
|
|
]);
|
|
$inflowCategory = Category::factory()->create([
|
|
'user_id' => $this->user->id,
|
|
'type' => CategoryType::Transfer,
|
|
'cashflow_direction' => CategoryCashflowDirection::Inflow,
|
|
'name' => 'From Relatives',
|
|
]);
|
|
|
|
$account = Account::factory()->create(['user_id' => $this->user->id]);
|
|
|
|
Transaction::factory()->create([
|
|
'user_id' => $this->user->id,
|
|
'account_id' => $account->id,
|
|
'category_id' => $inflowCategory->id,
|
|
'amount' => 80000,
|
|
'transaction_date' => now(),
|
|
]);
|
|
Transaction::factory()->create([
|
|
'user_id' => $this->user->id,
|
|
'account_id' => $account->id,
|
|
'category_id' => $expenseCategory->id,
|
|
'amount' => -20000,
|
|
'transaction_date' => now(),
|
|
]);
|
|
|
|
$response = $this->getJson('/api/cashflow/sankey?'.http_build_query([
|
|
'from' => now()->startOfMonth()->toDateString(),
|
|
'to' => now()->endOfMonth()->toDateString(),
|
|
]));
|
|
|
|
$response->assertOk();
|
|
$data = $response->json();
|
|
|
|
expect($data['total_income'])->toBe(80000);
|
|
expect($data['total_expense'])->toBe(20000);
|
|
|
|
$inflowIncome = collect($data['income_categories'])->firstWhere('category.name', 'From Relatives');
|
|
expect($inflowIncome)->not->toBeNull();
|
|
expect($inflowIncome['amount'])->toBe(80000);
|
|
|
|
// Inflow transfers should not appear on the expense side
|
|
expect(collect($data['expense_categories'])->pluck('category.name'))->not->toContain('From Relatives');
|
|
});
|
|
|
|
test('sankey nets mixed-sign inflow transfer categories on the income side', function () {
|
|
$inflowCategory = Category::factory()->create([
|
|
'user_id' => $this->user->id,
|
|
'type' => CategoryType::Transfer,
|
|
'cashflow_direction' => CategoryCashflowDirection::Inflow,
|
|
'name' => 'From account of relatives',
|
|
]);
|
|
|
|
$account = Account::factory()->create(['user_id' => $this->user->id]);
|
|
|
|
Transaction::factory()->create([
|
|
'user_id' => $this->user->id,
|
|
'account_id' => $account->id,
|
|
'category_id' => $inflowCategory->id,
|
|
'amount' => 300000,
|
|
'transaction_date' => now(),
|
|
]);
|
|
Transaction::factory()->create([
|
|
'user_id' => $this->user->id,
|
|
'account_id' => $account->id,
|
|
'category_id' => $inflowCategory->id,
|
|
'amount' => -300000,
|
|
'transaction_date' => now(),
|
|
]);
|
|
|
|
$response = $this->getJson('/api/cashflow/sankey?'.http_build_query([
|
|
'from' => now()->startOfMonth()->toDateString(),
|
|
'to' => now()->endOfMonth()->toDateString(),
|
|
]));
|
|
|
|
$response->assertOk();
|
|
$data = $response->json();
|
|
|
|
expect($data['total_income'])->toBe(0);
|
|
expect($data['total_expense'])->toBe(0);
|
|
expect(collect($data['income_categories'])->pluck('category.name'))->not->toContain('From account of relatives');
|
|
expect(collect($data['expense_categories'])->pluck('category.name'))->not->toContain('From account of relatives');
|
|
});
|
|
|
|
test('sankey includes only the net positive amount for inflow transfer categories', function () {
|
|
$inflowCategory = Category::factory()->create([
|
|
'user_id' => $this->user->id,
|
|
'type' => CategoryType::Transfer,
|
|
'cashflow_direction' => CategoryCashflowDirection::Inflow,
|
|
'name' => 'From account of relatives',
|
|
]);
|
|
|
|
$account = Account::factory()->create(['user_id' => $this->user->id]);
|
|
|
|
Transaction::factory()->create([
|
|
'user_id' => $this->user->id,
|
|
'account_id' => $account->id,
|
|
'category_id' => $inflowCategory->id,
|
|
'amount' => 500000,
|
|
'transaction_date' => now(),
|
|
]);
|
|
Transaction::factory()->create([
|
|
'user_id' => $this->user->id,
|
|
'account_id' => $account->id,
|
|
'category_id' => $inflowCategory->id,
|
|
'amount' => -200000,
|
|
'transaction_date' => now(),
|
|
]);
|
|
|
|
$response = $this->getJson('/api/cashflow/sankey?'.http_build_query([
|
|
'from' => now()->startOfMonth()->toDateString(),
|
|
'to' => now()->endOfMonth()->toDateString(),
|
|
]));
|
|
|
|
$response->assertOk();
|
|
$data = $response->json();
|
|
|
|
expect($data['total_income'])->toBe(300000);
|
|
expect($data['total_expense'])->toBe(0);
|
|
|
|
$inflowIncome = collect($data['income_categories'])->firstWhere('category.name', 'From account of relatives');
|
|
expect($inflowIncome)->not->toBeNull();
|
|
expect($inflowIncome['amount'])->toBe(300000);
|
|
expect(collect($data['expense_categories'])->pluck('category.name'))->not->toContain('From account of relatives');
|
|
});
|
|
|
|
test('sankey includes only the net negative amount for outflow transfer categories', function () {
|
|
$outflowCategory = Category::factory()->create([
|
|
'user_id' => $this->user->id,
|
|
'type' => CategoryType::Transfer,
|
|
'cashflow_direction' => CategoryCashflowDirection::Outflow,
|
|
'name' => 'Investments',
|
|
]);
|
|
|
|
$account = Account::factory()->create(['user_id' => $this->user->id]);
|
|
|
|
Transaction::factory()->create([
|
|
'user_id' => $this->user->id,
|
|
'account_id' => $account->id,
|
|
'category_id' => $outflowCategory->id,
|
|
'amount' => -500000,
|
|
'transaction_date' => now(),
|
|
]);
|
|
Transaction::factory()->create([
|
|
'user_id' => $this->user->id,
|
|
'account_id' => $account->id,
|
|
'category_id' => $outflowCategory->id,
|
|
'amount' => 200000,
|
|
'transaction_date' => now(),
|
|
]);
|
|
|
|
$response = $this->getJson('/api/cashflow/sankey?'.http_build_query([
|
|
'from' => now()->startOfMonth()->toDateString(),
|
|
'to' => now()->endOfMonth()->toDateString(),
|
|
]));
|
|
|
|
$response->assertOk();
|
|
$data = $response->json();
|
|
|
|
expect($data['total_income'])->toBe(0);
|
|
expect($data['total_expense'])->toBe(300000);
|
|
|
|
$outflowExpense = collect($data['expense_categories'])->firstWhere('category.name', 'Investments');
|
|
expect($outflowExpense)->not->toBeNull();
|
|
expect($outflowExpense['amount'])->toBe(300000);
|
|
expect(collect($data['income_categories'])->pluck('category.name'))->not->toContain('Investments');
|
|
});
|
|
|
|
test('breakdown includes unknown income category', function () {
|
|
$incomeCategory = Category::factory()->create([
|
|
'user_id' => $this->user->id,
|
|
'type' => CategoryType::Income,
|
|
'name' => 'Salary',
|
|
]);
|
|
|
|
$account = Account::factory()->create(['user_id' => $this->user->id]);
|
|
|
|
// Categorized income
|
|
Transaction::factory()->create([
|
|
'user_id' => $this->user->id,
|
|
'account_id' => $account->id,
|
|
'category_id' => $incomeCategory->id,
|
|
'amount' => 200000,
|
|
'transaction_date' => now(),
|
|
]);
|
|
|
|
// Uncategorized income
|
|
Transaction::factory()->create([
|
|
'user_id' => $this->user->id,
|
|
'account_id' => $account->id,
|
|
'category_id' => null,
|
|
'amount' => 50000,
|
|
'transaction_date' => now(),
|
|
]);
|
|
|
|
$response = $this->getJson('/api/cashflow/breakdown?'.http_build_query([
|
|
'from' => now()->startOfMonth()->toDateString(),
|
|
'to' => now()->endOfMonth()->toDateString(),
|
|
'type' => 'income',
|
|
]));
|
|
|
|
$response->assertOk();
|
|
$data = $response->json();
|
|
|
|
expect($data['total'])->toBe(250000);
|
|
expect($data['data'])->toHaveCount(2);
|
|
|
|
$unknownCategory = collect($data['data'])->firstWhere('category.name', 'Unknown Income');
|
|
expect($unknownCategory)->not->toBeNull();
|
|
expect($unknownCategory['amount'])->toBe(50000);
|
|
expect($unknownCategory['percentage'])->toEqual(20.0); // 50k / 250k = 20%
|
|
});
|
|
|
|
test('sankey rolls child category amounts up to their top-level parent by default', function () {
|
|
$account = Account::factory()->create(['user_id' => $this->user->id]);
|
|
$parent = Category::factory()->create([
|
|
'user_id' => $this->user->id,
|
|
'name' => 'Food',
|
|
'type' => CategoryType::Expense,
|
|
]);
|
|
$child = Category::factory()->childOf($parent)->create([
|
|
'user_id' => $this->user->id,
|
|
'name' => 'Groceries',
|
|
]);
|
|
|
|
// $100 directly on the parent, $50 on the child.
|
|
Transaction::factory()->create([
|
|
'user_id' => $this->user->id,
|
|
'account_id' => $account->id,
|
|
'category_id' => $parent->id,
|
|
'amount' => -10000,
|
|
'transaction_date' => now(),
|
|
]);
|
|
Transaction::factory()->create([
|
|
'user_id' => $this->user->id,
|
|
'account_id' => $account->id,
|
|
'category_id' => $child->id,
|
|
'amount' => -5000,
|
|
'transaction_date' => now(),
|
|
]);
|
|
|
|
$response = $this->getJson('/api/cashflow/sankey?'.http_build_query([
|
|
'from' => now()->startOfMonth()->toDateString(),
|
|
'to' => now()->endOfMonth()->toDateString(),
|
|
]));
|
|
|
|
$response->assertOk();
|
|
$expense = collect($response->json('expense_categories'));
|
|
|
|
expect($expense)->toHaveCount(1);
|
|
$node = $expense->first();
|
|
expect($node['category_id'])->toBe($parent->id)
|
|
->and($node['amount'])->toBe(15000)
|
|
->and($node['has_children'])->toBeTrue();
|
|
});
|
|
|
|
test('drilling into a parent splits it into children plus a direct node', function () {
|
|
$account = Account::factory()->create(['user_id' => $this->user->id]);
|
|
$parent = Category::factory()->create([
|
|
'user_id' => $this->user->id,
|
|
'name' => 'Food',
|
|
'type' => CategoryType::Expense,
|
|
]);
|
|
$child = Category::factory()->childOf($parent)->create([
|
|
'user_id' => $this->user->id,
|
|
'name' => 'Groceries',
|
|
]);
|
|
|
|
Transaction::factory()->create([
|
|
'user_id' => $this->user->id,
|
|
'account_id' => $account->id,
|
|
'category_id' => $parent->id,
|
|
'amount' => -10000,
|
|
'transaction_date' => now(),
|
|
]);
|
|
Transaction::factory()->create([
|
|
'user_id' => $this->user->id,
|
|
'account_id' => $account->id,
|
|
'category_id' => $child->id,
|
|
'amount' => -5000,
|
|
'transaction_date' => now(),
|
|
]);
|
|
|
|
$response = $this->getJson('/api/cashflow/sankey?'.http_build_query([
|
|
'from' => now()->startOfMonth()->toDateString(),
|
|
'to' => now()->endOfMonth()->toDateString(),
|
|
'parent' => $parent->id,
|
|
]));
|
|
|
|
$response->assertOk();
|
|
$expense = collect($response->json('expense_categories'));
|
|
|
|
expect($expense)->toHaveCount(2);
|
|
|
|
$childNode = $expense->firstWhere('is_direct', false);
|
|
expect($childNode['category_id'])->toBe($child->id)
|
|
->and($childNode['amount'])->toBe(5000);
|
|
|
|
$directNode = $expense->firstWhere('is_direct', true);
|
|
expect($directNode['category_id'])->toBe($parent->id)
|
|
->and($directNode['amount'])->toBe(10000);
|
|
});
|
|
|
|
test('sankey nested=1 returns the full three-level tree with rolled-up amounts', function () {
|
|
$account = Account::factory()->create(['user_id' => $this->user->id]);
|
|
|
|
$food = Category::factory()->create([
|
|
'user_id' => $this->user->id,
|
|
'name' => 'Food',
|
|
'type' => CategoryType::Expense,
|
|
]);
|
|
$groceries = Category::factory()->childOf($food)->create([
|
|
'user_id' => $this->user->id,
|
|
'name' => 'Groceries',
|
|
]);
|
|
$organic = Category::factory()->childOf($groceries)->create([
|
|
'user_id' => $this->user->id,
|
|
'name' => 'Organic',
|
|
]);
|
|
|
|
// $100 directly on Food, $50 directly on Groceries, $30 on Organic.
|
|
foreach ([[$food, -10000], [$groceries, -5000], [$organic, -3000]] as [$category, $amount]) {
|
|
Transaction::factory()->create([
|
|
'user_id' => $this->user->id,
|
|
'account_id' => $account->id,
|
|
'category_id' => $category->id,
|
|
'amount' => $amount,
|
|
'transaction_date' => now(),
|
|
]);
|
|
}
|
|
|
|
$response = $this->getJson('/api/cashflow/sankey?'.http_build_query([
|
|
'from' => now()->startOfMonth()->toDateString(),
|
|
'to' => now()->endOfMonth()->toDateString(),
|
|
'nested' => 1,
|
|
]));
|
|
|
|
$response->assertOk()->assertJsonPath('total_expense', 18000);
|
|
|
|
$expense = collect($response->json('expense_categories'));
|
|
expect($expense)->toHaveCount(1);
|
|
|
|
$foodNode = $expense->first();
|
|
expect($foodNode['category_id'])->toBe($food->id)
|
|
->and($foodNode['amount'])->toBe(18000);
|
|
|
|
$foodChildren = collect($foodNode['children']);
|
|
expect($foodChildren)->toHaveCount(2);
|
|
|
|
$groceriesNode = $foodChildren->firstWhere('category_id', $groceries->id);
|
|
expect($groceriesNode['is_direct'])->toBeFalse()
|
|
->and($groceriesNode['amount'])->toBe(8000);
|
|
|
|
$foodDirect = $foodChildren->firstWhere('is_direct', true);
|
|
expect($foodDirect['category_id'])->toBe($food->id)
|
|
->and($foodDirect['amount'])->toBe(10000);
|
|
|
|
$groceriesChildren = collect($groceriesNode['children']);
|
|
expect($groceriesChildren->firstWhere('category_id', $organic->id)['amount'])->toBe(3000)
|
|
->and($groceriesChildren->firstWhere('is_direct', true)['amount'])->toBe(5000);
|
|
});
|