404 lines
15 KiB
PHP
404 lines
15 KiB
PHP
<?php
|
|
|
|
use App\Enums\CategoryCashflowDirection;
|
|
use App\Enums\CategoryType;
|
|
use App\Enums\TransactionSource;
|
|
use App\Features\TransactionSplitting;
|
|
use App\Models\Budget;
|
|
use App\Models\BudgetPeriod;
|
|
use App\Models\BudgetTransaction;
|
|
use App\Models\Category;
|
|
use App\Models\Label;
|
|
use App\Models\Transaction;
|
|
use App\Models\TransactionSplit;
|
|
use App\Models\User;
|
|
use App\Services\BudgetTransactionService;
|
|
use App\Services\CategorySpendingService;
|
|
use App\Services\TransactionSplitter;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Laravel\Pennant\Feature;
|
|
|
|
use function Pest\Laravel\actingAs;
|
|
|
|
function splitTestExpenseCategory(User $user, string $name = 'Cat'): Category
|
|
{
|
|
return Category::factory()->for($user)->create([
|
|
'name' => $name,
|
|
'type' => CategoryType::Expense,
|
|
'cashflow_direction' => CategoryCashflowDirection::Outflow,
|
|
]);
|
|
}
|
|
|
|
function splittableTransaction(User $user, int $amount = -10000): Transaction
|
|
{
|
|
return Transaction::factory()->for($user)->create([
|
|
'amount' => $amount,
|
|
'category_id' => null,
|
|
'currency_code' => $user->currency_code,
|
|
'source' => TransactionSource::Imported,
|
|
]);
|
|
}
|
|
|
|
beforeEach(function () {
|
|
// Keep every amount in the user's own currency so analytics never reaches
|
|
// for an exchange rate (which would be a stray HTTP request under test).
|
|
$this->user = User::factory()->create(['currency_code' => 'EUR']);
|
|
Feature::for($this->user)->activate(TransactionSplitting::class);
|
|
});
|
|
|
|
it('splits a transaction into lines and nulls its own category', function () {
|
|
$restaurant = splitTestExpenseCategory($this->user, 'Restaurant');
|
|
$travel = splitTestExpenseCategory($this->user, 'Travel');
|
|
$transaction = splittableTransaction($this->user);
|
|
|
|
actingAs($this->user)
|
|
->patchJson(route('transactions.update', $transaction), [
|
|
'splits' => [
|
|
['category_id' => $restaurant->id, 'amount' => -2500, 'label_ids' => []],
|
|
['category_id' => $travel->id, 'amount' => -7500, 'label_ids' => []],
|
|
],
|
|
])
|
|
->assertOk();
|
|
|
|
$transaction->refresh();
|
|
|
|
expect($transaction->category_id)->toBeNull();
|
|
expect($transaction->splits)->toHaveCount(2);
|
|
expect($transaction->splits->sum('amount'))->toBe(-10000);
|
|
expect($transaction->splits->pluck('category_id')->all())
|
|
->toContain($restaurant->id, $travel->id);
|
|
});
|
|
|
|
it('moves transaction labels onto the split lines', function () {
|
|
$category = splitTestExpenseCategory($this->user);
|
|
$label = Label::factory()->for($this->user)->create();
|
|
$transaction = splittableTransaction($this->user);
|
|
$transaction->labels()->attach($label->id);
|
|
|
|
actingAs($this->user)
|
|
->patchJson(route('transactions.update', $transaction), [
|
|
'splits' => [
|
|
['category_id' => $category->id, 'amount' => -2500, 'label_ids' => [$label->id]],
|
|
['category_id' => $category->id, 'amount' => -7500, 'label_ids' => []],
|
|
],
|
|
])
|
|
->assertOk();
|
|
|
|
$transaction->refresh();
|
|
|
|
expect($transaction->labels)->toHaveCount(0);
|
|
expect($transaction->splits->first()->labels->pluck('id')->all())->toContain($label->id);
|
|
});
|
|
|
|
it('rejects splits that do not add up to the transaction amount', function () {
|
|
$category = splitTestExpenseCategory($this->user);
|
|
$transaction = splittableTransaction($this->user);
|
|
|
|
actingAs($this->user)
|
|
->patchJson(route('transactions.update', $transaction), [
|
|
'splits' => [
|
|
['category_id' => $category->id, 'amount' => -2500],
|
|
['category_id' => $category->id, 'amount' => -2500],
|
|
],
|
|
])
|
|
->assertJsonValidationErrors(['splits']);
|
|
|
|
expect($transaction->fresh()->splits)->toHaveCount(0);
|
|
});
|
|
|
|
it('rejects a single split line', function () {
|
|
$category = splitTestExpenseCategory($this->user);
|
|
$transaction = splittableTransaction($this->user);
|
|
|
|
actingAs($this->user)
|
|
->patchJson(route('transactions.update', $transaction), [
|
|
'splits' => [
|
|
['category_id' => $category->id, 'amount' => -10000],
|
|
],
|
|
])
|
|
->assertJsonValidationErrors(['splits']);
|
|
});
|
|
|
|
it('rejects a split line whose sign differs from the transaction', function () {
|
|
$category = splitTestExpenseCategory($this->user);
|
|
$transaction = splittableTransaction($this->user);
|
|
|
|
actingAs($this->user)
|
|
->patchJson(route('transactions.update', $transaction), [
|
|
'splits' => [
|
|
['category_id' => $category->id, 'amount' => -12500],
|
|
['category_id' => $category->id, 'amount' => 2500],
|
|
],
|
|
])
|
|
->assertJsonValidationErrors(['splits.1.amount']);
|
|
});
|
|
|
|
it('collapses a split back to a single category', function () {
|
|
$category = splitTestExpenseCategory($this->user);
|
|
$target = splitTestExpenseCategory($this->user, 'Target');
|
|
$transaction = splittableTransaction($this->user);
|
|
|
|
app(TransactionSplitter::class)->apply($transaction, [
|
|
['category_id' => $category->id, 'amount' => -2500],
|
|
['category_id' => $category->id, 'amount' => -7500],
|
|
]);
|
|
|
|
actingAs($this->user)
|
|
->patchJson(route('transactions.update', $transaction), [
|
|
'category_id' => $target->id,
|
|
'splits' => [],
|
|
])
|
|
->assertOk();
|
|
|
|
$transaction->refresh();
|
|
|
|
expect($transaction->splits)->toHaveCount(0);
|
|
expect($transaction->category_id)->toBe($target->id);
|
|
});
|
|
|
|
it('forbids splitting when the feature is disabled', function () {
|
|
Feature::for($this->user)->deactivate(TransactionSplitting::class);
|
|
|
|
$category = splitTestExpenseCategory($this->user);
|
|
$transaction = splittableTransaction($this->user);
|
|
|
|
actingAs($this->user)
|
|
->patchJson(route('transactions.update', $transaction), [
|
|
'splits' => [
|
|
['category_id' => $category->id, 'amount' => -2500],
|
|
['category_id' => $category->id, 'amount' => -7500],
|
|
],
|
|
])
|
|
->assertForbidden();
|
|
});
|
|
|
|
it('cascade-deletes split lines when the transaction row is removed', function () {
|
|
$category = splitTestExpenseCategory($this->user);
|
|
$transaction = splittableTransaction($this->user);
|
|
|
|
app(TransactionSplitter::class)->apply($transaction, [
|
|
['category_id' => $category->id, 'amount' => -2500],
|
|
['category_id' => $category->id, 'amount' => -7500],
|
|
]);
|
|
|
|
// Hit the DB-level foreign-key cascade directly (a model force-delete would
|
|
// additionally fire the queued TransactionDeleted listener).
|
|
DB::table('transactions')->where('id', $transaction->id)->delete();
|
|
|
|
expect(TransactionSplit::query()->where('transaction_id', $transaction->id)->count())->toBe(0);
|
|
});
|
|
|
|
it('attributes split lines to their categories in the spending breakdown', function () {
|
|
$restaurant = splitTestExpenseCategory($this->user, 'Restaurant');
|
|
$travel = splitTestExpenseCategory($this->user, 'Travel');
|
|
$transaction = splittableTransaction($this->user, -10000);
|
|
$transaction->update(['transaction_date' => now()]);
|
|
|
|
app(TransactionSplitter::class)->apply($transaction, [
|
|
['category_id' => $restaurant->id, 'amount' => -2500],
|
|
['category_id' => $travel->id, 'amount' => -7500],
|
|
]);
|
|
|
|
$spending = app(CategorySpendingService::class)->forPeriod(
|
|
$this->user->id,
|
|
now()->subDay(),
|
|
now()->addDay(),
|
|
);
|
|
|
|
$byCategory = $spending->keyBy('category_id');
|
|
|
|
expect($byCategory[$restaurant->id]['amount'])->toBe(2500);
|
|
expect($byCategory[$travel->id]['amount'])->toBe(7500);
|
|
});
|
|
|
|
it('reflects split lines in the cashflow expense breakdown endpoint', function () {
|
|
$restaurant = splitTestExpenseCategory($this->user, 'Restaurant');
|
|
$travel = splitTestExpenseCategory($this->user, 'Travel');
|
|
$transaction = splittableTransaction($this->user, -10000);
|
|
$transaction->update(['transaction_date' => now()]);
|
|
|
|
app(TransactionSplitter::class)->apply($transaction, [
|
|
['category_id' => $restaurant->id, 'amount' => -2500],
|
|
['category_id' => $travel->id, 'amount' => -7500],
|
|
]);
|
|
|
|
$response = actingAs($this->user)->getJson(
|
|
'/api/cashflow/breakdown?type=expense&from='.now()->subDay()->toDateString().'&to='.now()->addDay()->toDateString(),
|
|
)->assertOk();
|
|
|
|
$amounts = collect($response->json('data'))->keyBy('category_id');
|
|
|
|
expect($amounts[$restaurant->id]['amount'])->toBe(2500);
|
|
expect($amounts[$travel->id]['amount'])->toBe(7500);
|
|
});
|
|
|
|
it('attributes only the matching split line to a category budget', function () {
|
|
$tracked = splitTestExpenseCategory($this->user, 'Tracked');
|
|
$other = splitTestExpenseCategory($this->user, 'Other');
|
|
$transaction = splittableTransaction($this->user, -10000);
|
|
$transaction->update(['transaction_date' => now()]);
|
|
|
|
app(TransactionSplitter::class)->apply($transaction, [
|
|
['category_id' => $tracked->id, 'amount' => -2500],
|
|
['category_id' => $other->id, 'amount' => -7500],
|
|
]);
|
|
|
|
$budget = Budget::factory()->forCategories($tracked)->create(['user_id' => $this->user->id]);
|
|
$period = BudgetPeriod::factory()->create([
|
|
'budget_id' => $budget->id,
|
|
'start_date' => now()->subDays(15),
|
|
'end_date' => now()->addDays(15),
|
|
]);
|
|
|
|
app(BudgetTransactionService::class)->assignTransaction($transaction->fresh());
|
|
|
|
$budgetTransaction = BudgetTransaction::query()
|
|
->where('transaction_id', $transaction->id)
|
|
->where('budget_period_id', $period->id)
|
|
->first();
|
|
|
|
expect($budgetTransaction)->not->toBeNull();
|
|
expect($budgetTransaction->amount)->toBe(2500);
|
|
});
|
|
|
|
it('finds a split transaction when filtering the list by a line category', function () {
|
|
$restaurant = splitTestExpenseCategory($this->user, 'Restaurant');
|
|
$travel = splitTestExpenseCategory($this->user, 'Travel');
|
|
$transaction = splittableTransaction($this->user, -10000);
|
|
|
|
app(TransactionSplitter::class)->apply($transaction, [
|
|
['category_id' => $restaurant->id, 'amount' => -2500],
|
|
['category_id' => $travel->id, 'amount' => -7500],
|
|
]);
|
|
|
|
$matches = Transaction::query()
|
|
->where('user_id', $this->user->id)
|
|
->applyFilters(['category_ids' => [$restaurant->id], 'user_id' => $this->user->id])
|
|
->pluck('id');
|
|
|
|
expect($matches->all())->toContain($transaction->id);
|
|
});
|
|
|
|
it('does not treat a fully-categorized split as uncategorized', function () {
|
|
$restaurant = splitTestExpenseCategory($this->user, 'Restaurant');
|
|
$travel = splitTestExpenseCategory($this->user, 'Travel');
|
|
$split = splittableTransaction($this->user, -10000);
|
|
$uncategorized = splittableTransaction($this->user, -5000);
|
|
|
|
app(TransactionSplitter::class)->apply($split, [
|
|
['category_id' => $restaurant->id, 'amount' => -2500],
|
|
['category_id' => $travel->id, 'amount' => -7500],
|
|
]);
|
|
|
|
$matches = Transaction::query()
|
|
->where('user_id', $this->user->id)
|
|
->applyFilters(['category_ids' => ['uncategorized'], 'user_id' => $this->user->id])
|
|
->pluck('id');
|
|
|
|
expect($matches->all())->toContain($uncategorized->id);
|
|
expect($matches->all())->not->toContain($split->id);
|
|
});
|
|
|
|
it('rejects splitting a zero-amount transaction', function () {
|
|
$category = splitTestExpenseCategory($this->user);
|
|
$transaction = splittableTransaction($this->user, 0);
|
|
|
|
actingAs($this->user)
|
|
->patchJson(route('transactions.update', $transaction), [
|
|
'splits' => [
|
|
['category_id' => $category->id, 'amount' => 50],
|
|
['category_id' => $category->id, 'amount' => -50],
|
|
],
|
|
])
|
|
->assertJsonValidationErrors(['splits']);
|
|
});
|
|
|
|
it('classifies split lines by their own category type in the dashboard cash flow', function () {
|
|
$expense = splitTestExpenseCategory($this->user, 'Groceries');
|
|
$savings = Category::factory()->for($this->user)->create([
|
|
'name' => 'Savings',
|
|
'type' => CategoryType::Savings,
|
|
'cashflow_direction' => CategoryCashflowDirection::Outflow,
|
|
]);
|
|
$transaction = splittableTransaction($this->user, -10000);
|
|
$transaction->update(['transaction_date' => now()]);
|
|
|
|
app(TransactionSplitter::class)->apply($transaction, [
|
|
['category_id' => $expense->id, 'amount' => -3000],
|
|
['category_id' => $savings->id, 'amount' => -7000],
|
|
]);
|
|
|
|
$response = actingAs($this->user)->getJson(
|
|
'/api/dashboard/cash-flow?from='.now()->startOfMonth()->toDateString().'&to='.now()->endOfMonth()->toDateString(),
|
|
)->assertOk();
|
|
|
|
// Only the expense line counts as expense; the savings line is excluded.
|
|
expect($response->json('current.expense'))->toBe(3000);
|
|
});
|
|
|
|
it('attributes split lines per category on the analysis screen', function () {
|
|
$restaurant = splitTestExpenseCategory($this->user, 'Restaurant');
|
|
$travel = splitTestExpenseCategory($this->user, 'Travel');
|
|
$transaction = splittableTransaction($this->user, -10000);
|
|
$transaction->update(['transaction_date' => now()]);
|
|
|
|
app(TransactionSplitter::class)->apply($transaction, [
|
|
['category_id' => $restaurant->id, 'amount' => -2500],
|
|
['category_id' => $travel->id, 'amount' => -7500],
|
|
]);
|
|
|
|
$response = actingAs($this->user)->getJson(
|
|
'/api/transactions/analysis?date_from='.now()->subDay()->toDateString().'&date_to='.now()->addDay()->toDateString(),
|
|
)->assertOk();
|
|
|
|
$byCategory = collect($response->json('by_category'))->keyBy('category_id');
|
|
|
|
expect($byCategory[$restaurant->id]['amount'])->toBe(2500);
|
|
expect($byCategory[$travel->id]['amount'])->toBe(7500);
|
|
});
|
|
|
|
it('skips split transactions on a bulk label assignment', function () {
|
|
$category = splitTestExpenseCategory($this->user);
|
|
$label = Label::factory()->for($this->user)->create();
|
|
$split = splittableTransaction($this->user, -10000);
|
|
$plain = splittableTransaction($this->user, -5000);
|
|
|
|
app(TransactionSplitter::class)->apply($split, [
|
|
['category_id' => $category->id, 'amount' => -2500],
|
|
['category_id' => $category->id, 'amount' => -7500],
|
|
]);
|
|
|
|
actingAs($this->user)
|
|
->patchJson(route('transactions.bulk-update'), [
|
|
'transaction_ids' => [$split->id, $plain->id],
|
|
'label_ids' => [$label->id],
|
|
])
|
|
->assertOk();
|
|
|
|
expect($split->fresh()->labels)->toHaveCount(0);
|
|
expect($plain->fresh()->labels->pluck('id')->all())->toContain($label->id);
|
|
});
|
|
|
|
it('skips split transactions on a bulk category assignment', function () {
|
|
$category = splitTestExpenseCategory($this->user);
|
|
$newCategory = splitTestExpenseCategory($this->user, 'New');
|
|
$split = splittableTransaction($this->user, -10000);
|
|
$plain = splittableTransaction($this->user, -5000);
|
|
|
|
app(TransactionSplitter::class)->apply($split, [
|
|
['category_id' => $category->id, 'amount' => -2500],
|
|
['category_id' => $category->id, 'amount' => -7500],
|
|
]);
|
|
|
|
$response = actingAs($this->user)
|
|
->patchJson(route('transactions.bulk-update'), [
|
|
'transaction_ids' => [$split->id, $plain->id],
|
|
'category_id' => $newCategory->id,
|
|
])
|
|
->assertOk();
|
|
|
|
expect($response->json('omitted_splits'))->toBe(1);
|
|
expect($split->fresh()->category_id)->toBeNull();
|
|
expect($plain->fresh()->category_id)->toBe($newCategory->id);
|
|
});
|