user_id; if (! $userId) { return; } $allocations = $this->allocationsFor($transaction); $claimedCategoryIds = $this->claimedCategoryIds($userId); // period_id => aggregated amount (already negated: expenses are positive // spend in a budget). Periods that net to zero are dropped below. $periodAmounts = []; foreach ($this->candidatePeriods($transaction, $userId, $allocations) as $period) { $amount = $this->matchedAmountForBudget($allocations, $period->budget, $userId, $claimedCategoryIds); if ($amount !== 0) { $periodAmounts[$period->id] = $amount; } } $matchingPeriodIds = array_keys($periodAmounts); // Apply changes atomically so concurrent workers cannot leave the // transaction half-assigned and the unique index guards duplicates. DB::transaction(function () use ($transaction, $periodAmounts, $matchingPeriodIds) { Transaction::query() ->whereKey($transaction->id) ->lockForUpdate() ->first(); BudgetTransaction::query() ->where('transaction_id', $transaction->id) ->when( $matchingPeriodIds !== [], fn ($q) => $q->whereNotIn('budget_period_id', $matchingPeriodIds), ) ->delete(); foreach ($periodAmounts as $periodId => $amount) { BudgetTransaction::updateOrCreate( [ 'transaction_id' => $transaction->id, 'budget_period_id' => $periodId, ], [ 'amount' => $amount, ], ); } }, attempts: 5); } public function unassignTransaction(Transaction $transaction): void { BudgetTransaction::where('transaction_id', $transaction->id)->delete(); } public function assignHistoricalTransactionsToPeriod(BudgetPeriod $period): int { $budget = $period->budget()->with(['categories:id', 'labels:id'])->first(); if (! $budget) { return 0; } $claimedCategoryIds = $this->claimedCategoryIds($budget->user_id); $assignedCount = 0; // Evaluate every transaction in range against this budget. Split-aware // matching lives on the lines, so we can't pre-filter by the parent's // category_id/labels in SQL. // ponytail: full period scan (chunked); narrow to matched-or-split rows // if a large backfill becomes slow. Transaction::query() ->where('user_id', $budget->user_id) ->whereBetween('transaction_date', [$period->start_date, $period->end_date]) ->withoutTrashed() ->with(['category:id,type', 'labels:id', 'splits.category:id,type', 'splits.labels:id']) ->chunk(500, function (Collection $transactions) use ($period, $budget, $claimedCategoryIds, &$assignedCount): void { foreach ($transactions as $transaction) { $amount = $this->matchedAmountForBudget( $this->allocationsFor($transaction), $budget, $budget->user_id, $claimedCategoryIds, ); if ($amount === 0) { continue; } $budgetTransaction = BudgetTransaction::updateOrCreate( [ 'transaction_id' => $transaction->id, 'budget_period_id' => $period->id, ], [ 'amount' => $amount, ], ); if ($budgetTransaction->wasRecentlyCreated) { $assignedCount++; } } }); return $assignedCount; } /** * The transaction's category attributions: one entry for an unsplit * transaction, one per line for a split one. * * @return array, amount: int}> */ private function allocationsFor(Transaction $transaction): array { if ($transaction->isSplit()) { $transaction->loadMissing(['splits.category', 'splits.labels']); return $transaction->splits->map(fn ($line): array => [ 'categoryId' => $line->category_id, 'categoryType' => $line->category?->type, 'labelIds' => $line->labels->pluck('id')->all(), 'amount' => $line->amount, ])->all(); } $transaction->loadMissing(['category', 'labels']); return [[ 'categoryId' => $transaction->category_id, 'categoryType' => $transaction->category?->type, 'labelIds' => $transaction->labels->pluck('id')->all(), 'amount' => $transaction->amount, ]]; } /** * Budget periods in the transaction's date range that could match any of its * allocations — by tracked category, tracked label, or being a catch-all. * * @param array, amount: int}> $allocations * @return Collection */ private function candidatePeriods(Transaction $transaction, string $userId, array $allocations): Collection { $categoryMatchIds = []; $labelIds = []; foreach ($allocations as $allocation) { if ($allocation['categoryId'] !== null) { $categoryMatchIds = array_merge($categoryMatchIds, $this->tree->ancestorAndSelfIds($userId, $allocation['categoryId'])); } $labelIds = array_merge($labelIds, $allocation['labelIds']); } $categoryMatchIds = array_values(array_unique($categoryMatchIds)); $labelIds = array_values(array_unique($labelIds)); return BudgetPeriod::query() ->whereHas('budget', function ($query) use ($userId, $categoryMatchIds, $labelIds) { $query->where('user_id', $userId) ->where(function ($inner) use ($categoryMatchIds, $labelIds) { $inner->where('is_catch_all', true) ->orWhereHas('categories', fn ($cq) => $cq->whereIn('categories.id', $categoryMatchIds)) ->orWhereHas('labels', fn ($lq) => $lq->whereIn('labels.id', $labelIds)); }); }) ->where('start_date', '<=', $transaction->transaction_date) ->where('end_date', '>=', $transaction->transaction_date) ->with('budget.categories:id', 'budget.labels:id') ->get(); } /** * The portion of a transaction's amount that belongs to a budget: the sum of * the allocations that match it (negated so expenses count as positive * spend), or zero when none match. * * @param array, amount: int}> $allocations * @param array $claimedCategoryIds */ private function matchedAmountForBudget(array $allocations, Budget $budget, string $userId, array $claimedCategoryIds): int { $total = 0; foreach ($allocations as $allocation) { if ($this->allocationMatchesBudget($allocation, $budget, $userId, $claimedCategoryIds)) { $total += -$allocation['amount']; } } return $total; } /** * @param array{categoryId: ?string, categoryType: ?CategoryType, labelIds: array, amount: int} $allocation * @param array $claimedCategoryIds */ private function allocationMatchesBudget(array $allocation, Budget $budget, string $userId, array $claimedCategoryIds): bool { if ($budget->is_catch_all) { return $this->allocationHitsCatchAll($allocation, $userId, $claimedCategoryIds); } $categoryMatchIds = $allocation['categoryId'] !== null ? $this->tree->ancestorAndSelfIds($userId, $allocation['categoryId']) : []; $matchesCategory = $categoryMatchIds !== [] && $budget->categories->pluck('id')->intersect($categoryMatchIds)->isNotEmpty(); $matchesLabel = $budget->labels ->pluck('id') ->intersect($allocation['labelIds']) ->isNotEmpty(); return $matchesCategory || $matchesLabel; } /** * A catch-all budget absorbs an expense allocation whose category (or an * ancestor) is not tracked by any of the user's other budgets. * * @param array{categoryId: ?string, categoryType: ?CategoryType, labelIds: array, amount: int} $allocation * @param array $claimedCategoryIds */ private function allocationHitsCatchAll(array $allocation, string $userId, array $claimedCategoryIds): bool { if ($allocation['categoryId'] === null || $allocation['categoryType'] !== CategoryType::Expense) { return false; } $categoryMatchIds = $this->tree->ancestorAndSelfIds($userId, $allocation['categoryId']); return array_intersect($categoryMatchIds, $claimedCategoryIds) === []; } /** * Category ids directly tracked by the user's non-catch-all budgets. * * @return array */ private function claimedCategoryIds(string $userId): array { return Budget::query() ->where('user_id', $userId) ->where('is_catch_all', false) ->with('categories:id') ->get() ->flatMap(fn (Budget $budget) => $budget->categories->pluck('id')) ->unique() ->values() ->all(); } }