whisper-money/app/Services/BudgetTransactionService.php

291 lines
11 KiB
PHP

<?php
namespace App\Services;
use App\Enums\CategoryType;
use App\Models\Budget;
use App\Models\BudgetPeriod;
use App\Models\BudgetTransaction;
use App\Models\Transaction;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
class BudgetTransactionService
{
public function __construct(
private readonly CategoryTree $tree = new CategoryTree,
private readonly BudgetNotificationService $notifications = new BudgetNotificationService,
) {}
/**
* @param bool $notify set to false for bulk backfills, where the emails
* would describe budget states the user never crossed
*/
public function assignTransaction(Transaction $transaction, bool $notify = true): void
{
$userId = $transaction->user_id;
if (! $userId) {
return;
}
// 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 = array_merge(
$matchingPeriodIds,
$this->catchAllPeriodIds($transaction, $userId, $categoryMatchIds, $transactionLabelIds->all()),
);
// Apply changes atomically so concurrent workers cannot leave the
// transaction half-assigned and the unique index guards duplicates.
$createdPeriodIds = [];
DB::transaction(function () use ($transaction, $matchingPeriodIds, &$createdPeriodIds) {
// Reset per attempt: a deadlock retry re-runs this closure.
$createdPeriodIds = [];
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 ($matchingPeriodIds as $periodId) {
$budgetTransaction = BudgetTransaction::updateOrCreate(
[
'transaction_id' => $transaction->id,
'budget_period_id' => $periodId,
],
[
'amount' => -$transaction->amount,
],
);
if ($budgetTransaction->wasRecentlyCreated) {
$createdPeriodIds[] = $periodId;
}
}
}, attempts: 5);
if ($notify) {
$this->notifications->handleAssignment($transaction, $matchingPeriodIds, $createdPeriodIds);
}
}
public function unassignTransaction(Transaction $transaction): void
{
BudgetTransaction::where('transaction_id', $transaction->id)->delete();
}
public function assignHistoricalTransactionsToPeriod(BudgetPeriod $period): int
{
// Load the budget with its relationships
$budget = $period->budget()->with(['categories:id', 'labels:id'])->first();
if (! $budget) {
return 0;
}
$assignedCount = 0;
// Tracking a parent category also tracks its children's spending.
$categoryIds = collect($this->tree->expand($budget->user_id, $budget->categories->pluck('id')->all()));
$labelIds = $budget->labels->pluck('id');
Log::info('Building query for historical transactions', [
'user_id' => $budget->user_id,
'category_ids' => $categoryIds->all(),
'label_ids' => $labelIds->all(),
'start_date' => $period->start_date->toDateString(),
'end_date' => $period->end_date->toDateString(),
]);
// Build the query for matching transactions
$query = Transaction::query()
->where('user_id', $budget->user_id)
->whereBetween('transaction_date', [$period->start_date, $period->end_date])
->withoutTrashed();
if ($budget->is_catch_all) {
$this->applyCatchAllFilters($query, $budget->user_id);
} else {
// Filter by any tracked category OR label
$query->where(function ($q) use ($categoryIds, $labelIds) {
if ($categoryIds->isNotEmpty()) {
$q->whereIn('category_id', $categoryIds);
}
if ($labelIds->isNotEmpty()) {
$q->orWhereHas('labels', function ($labelQuery) use ($labelIds) {
$labelQuery->whereIn('labels.id', $labelIds);
});
}
});
}
$totalCount = $query->count();
Log::info("Found {$totalCount} transactions to process in date range");
// Process in chunks to prevent memory issues
$query->chunk(500, function ($transactions) use ($period, &$assignedCount) {
foreach ($transactions as $transaction) {
$budgetTransaction = BudgetTransaction::updateOrCreate(
[
'transaction_id' => $transaction->id,
'budget_period_id' => $period->id,
],
[
'amount' => -$transaction->amount,
],
);
if ($budgetTransaction->wasRecentlyCreated) {
$assignedCount++;
}
}
});
return $assignedCount;
}
/**
* Narrow a transaction query to what a catch-all budget absorbs: expenses
* whose category and labels are not already tracked by another budget.
*
* @param Builder<Transaction> $query
*/
private function applyCatchAllFilters(Builder $query, string $userId): void
{
$claimed = $this->claimedIds($userId);
$claimedCategoryIds = $this->tree->expand($userId, $claimed['categories']);
$query->whereNotNull('category_id')
->when(
$claimedCategoryIds !== [],
fn ($q) => $q->whereNotIn('category_id', $claimedCategoryIds),
)
->when(
$claimed['labels'] !== [],
fn ($q) => $q->whereDoesntHave(
'labels',
fn ($labelQuery) => $labelQuery->whereIn('labels.id', $claimed['labels']),
),
)
->whereHas('category', fn ($q) => $q->where('type', CategoryType::Expense->value));
}
/**
* Catch-all budget periods that should absorb this transaction: an expense
* whose category (or an ancestor) and labels are not tracked by any
* non-catch-all budget.
*
* @param array<int, string> $categoryMatchIds the transaction category and its ancestors
* @param array<int, string> $transactionLabelIds
* @return array<int, string>
*/
private function catchAllPeriodIds(Transaction $transaction, string $userId, array $categoryMatchIds, array $transactionLabelIds): array
{
if ($transaction->category_id === null) {
return [];
}
$transaction->loadMissing('category');
if ($transaction->category?->type !== CategoryType::Expense) {
return [];
}
$claimed = $this->claimedIds($userId);
if (array_intersect($categoryMatchIds, $claimed['categories']) !== []) {
return [];
}
if (array_intersect($transactionLabelIds, $claimed['labels']) !== []) {
return [];
}
return BudgetPeriod::query()
->whereHas('budget', function ($query) use ($userId) {
$query->where('user_id', $userId)->where('is_catch_all', true);
})
->where('start_date', '<=', $transaction->transaction_date)
->where('end_date', '>=', $transaction->transaction_date)
->pluck('id')
->all();
}
/**
* Categories and labels directly tracked by the user's non-catch-all budgets.
*
* @return array{categories: array<int, string>, labels: array<int, string>}
*/
private function claimedIds(string $userId): array
{
$budgets = Budget::query()
->where('user_id', $userId)
->where('is_catch_all', false)
->with('categories:id', 'labels:id')
->get();
return [
'categories' => $budgets->flatMap(fn (Budget $budget) => $budget->categories->pluck('id'))->unique()->values()->all(),
'labels' => $budgets->flatMap(fn (Budget $budget) => $budget->labels->pluck('id'))->unique()->values()->all(),
];
}
}