refactor(budgets): extract the tracked-period lookup out of assignTransaction

It carried the whole category/label matching inline, which put the method over
the complexity threshold once precedence added a branch.
This commit is contained in:
Víctor Falcón 2026-08-12 11:29:07 +02:00
parent 091457c747
commit 7edbff10ac
1 changed files with 57 additions and 44 deletions

View File

@ -33,50 +33,7 @@ class BudgetTransactionService
// Ensure labels are available for matching (safe if already loaded).
$transaction->loadMissing('labels');
$transactionLabelIds = $transaction->labels->pluck('id');
// A budget tracking a parent category also covers its children, so a
// transaction matches a budget when any of its category's ancestors
// (or itself) is attached to that budget.
$categoryMatchIds = $transaction->category_id
? $this->tree->ancestorAndSelfIds($userId, $transaction->category_id)
: [];
// Find budget periods that potentially match this transaction.
$budgetPeriods = BudgetPeriod::query()
->whereHas('budget', function ($query) use ($categoryMatchIds, $transactionLabelIds, $userId) {
$query->where('user_id', $userId)
->where(function ($q) use ($categoryMatchIds, $transactionLabelIds) {
$q->whereHas('categories', function ($cq) use ($categoryMatchIds) {
$cq->whereIn('categories.id', $categoryMatchIds);
})
->orWhereHas('labels', function ($lq) use ($transactionLabelIds) {
$lq->whereIn('labels.id', $transactionLabelIds);
});
});
})
->where('start_date', '<=', $transaction->transaction_date)
->where('end_date', '>=', $transaction->transaction_date)
->with('budget.categories:id', 'budget.labels:id')
->get();
// Narrow down to periods whose budget actually matches the transaction.
$matchingPeriodIds = [];
foreach ($budgetPeriods as $period) {
$budget = $period->budget;
$matchesCategory = $categoryMatchIds !== []
&& $budget->categories->pluck('id')->intersect($categoryMatchIds)->isNotEmpty();
$matchesLabel = $budget->labels
->pluck('id')
->intersect($transactionLabelIds)
->isNotEmpty();
if ($matchesCategory || $matchesLabel) {
$matchingPeriodIds[] = $period->id;
}
}
$matchingPeriodIds = $this->trackedPeriodIds($transaction, $userId);
// A catch-all budget only absorbs what nothing else counts. Any budget
// already tracking this transaction — by category or by label — in a
@ -231,6 +188,62 @@ class BudgetTransactionService
->whereHas('category', fn ($q) => $q->where('type', CategoryType::Expense->value));
}
/**
* Budget periods that track this transaction by category or by label and
* cover its date.
*
* @return array<int, string>
*/
private function trackedPeriodIds(Transaction $transaction, string $userId): array
{
$transactionLabelIds = $transaction->labels->pluck('id');
// A budget tracking a parent category also covers its children, so a
// transaction matches a budget when any of its category's ancestors
// (or itself) is attached to that budget.
$categoryMatchIds = $transaction->category_id
? $this->tree->ancestorAndSelfIds($userId, $transaction->category_id)
: [];
// Find budget periods that potentially match this transaction.
$budgetPeriods = BudgetPeriod::query()
->whereHas('budget', function ($query) use ($categoryMatchIds, $transactionLabelIds, $userId) {
$query->where('user_id', $userId)
->where(function ($q) use ($categoryMatchIds, $transactionLabelIds) {
$q->whereHas('categories', function ($cq) use ($categoryMatchIds) {
$cq->whereIn('categories.id', $categoryMatchIds);
})
->orWhereHas('labels', function ($lq) use ($transactionLabelIds) {
$lq->whereIn('labels.id', $transactionLabelIds);
});
});
})
->where('start_date', '<=', $transaction->transaction_date)
->where('end_date', '>=', $transaction->transaction_date)
->with('budget.categories:id', 'budget.labels:id')
->get();
// Narrow down to periods whose budget actually matches the transaction.
$matchingPeriodIds = [];
foreach ($budgetPeriods as $period) {
$budget = $period->budget;
$matchesCategory = $categoryMatchIds !== []
&& $budget->categories->pluck('id')->intersect($categoryMatchIds)->isNotEmpty();
$matchesLabel = $budget->labels
->pluck('id')
->intersect($transactionLabelIds)
->isNotEmpty();
if ($matchesCategory || $matchesLabel) {
$matchingPeriodIds[] = $period->id;
}
}
return $matchingPeriodIds;
}
/**
* Catch-all budget periods that should absorb this expense.
*