Collection of Draw models (unsaved) */ public function allocateInflow(Scenario $scenario, int $amount, ?Carbon $date = null, ?string $description = null): Collection { if ($amount <= 0) { return collect(); } $buckets = $scenario->buckets() ->orderBy('priority') ->get(); if ($buckets->isEmpty()) { return collect(); } $mode = $scenario->distribution_mode ?? DistributionModeEnum::EVEN; $allocationDate = $date ?? now(); $allocationDescription = $description ?? 'Allocation from inflow'; // Track cumulative allocations per bucket across phases /** @var array $allocated bucket_id => total allocated cents */ $allocated = []; $needs = $buckets->filter(fn (Bucket $b) => $b->type === BucketTypeEnum::NEED); $wants = $buckets->filter(fn (Bucket $b) => $b->type === BucketTypeEnum::WANT); $overflow = $buckets->first(fn (Bucket $b) => $b->type === BucketTypeEnum::OVERFLOW); $remaining = $amount; // Phases 1-4: needs/wants to base/buffer $phases = [ [$needs, self::CAP_BASE], [$wants, self::CAP_BASE], [$needs, self::CAP_FILL], [$wants, self::CAP_FILL], ]; foreach ($phases as [$group, $cap]) { $remaining = $this->allocateToGroup($group, $remaining, $mode, $cap, $allocated); } // Phase 5: Overflow if ($remaining > 0 && $overflow !== null) { $allocated[$overflow->id] = ($allocated[$overflow->id] ?? 0) + $remaining; } // Build Draw models from cumulative allocations $draws = collect(); foreach ($allocated as $bucketId => $totalAmount) { if ($totalAmount <= 0) { continue; } $draws->push(new Draw([ 'bucket_id' => $bucketId, 'amount' => $totalAmount, 'date' => $allocationDate, 'description' => $allocationDescription, 'is_projected' => true, ])); } return $draws; } /** * Allocate money to a group of buckets up to a capacity target. * * @param Collection $buckets Buckets ordered by priority * @param int $amount Available money in cents * @param DistributionModeEnum $mode Even or priority distribution * @param string $capMethod self::CAP_BASE = allocation_value, self::CAP_FILL = getEffectiveCapacity() * @param array &$allocated Cumulative allocations per bucket (mutated) * @return int Remaining amount after allocation */ private function allocateToGroup(Collection $buckets, int $amount, DistributionModeEnum $mode, string $capMethod, array &$allocated): int { if ($amount <= 0 || $buckets->isEmpty()) { return $amount; } return match ($mode) { DistributionModeEnum::PRIORITY => $this->allocatePriority($buckets, $amount, $capMethod, $allocated), DistributionModeEnum::EVEN => $this->allocateEven($buckets, $amount, $capMethod, $allocated), }; } /** * Priority mode: fill highest-priority bucket first, then next. */ private function allocatePriority(Collection $buckets, int $amount, string $capMethod, array &$allocated): int { $remaining = $amount; foreach ($buckets as $bucket) { if ($remaining <= 0) { break; } $space = $this->getAvailableSpace($bucket, $capMethod, $allocated); if ($space <= 0) { continue; } $allocation = min($space, $remaining); $allocated[$bucket->id] = ($allocated[$bucket->id] ?? 0) + $allocation; $remaining -= $allocation; } return $remaining; } /** * Even mode: split evenly across eligible buckets, redistributing when one fills up. */ private function allocateEven(Collection $buckets, int $amount, string $capMethod, array &$allocated): int { $remaining = $amount; // Filter to buckets that still have space $eligible = $buckets->filter(fn (Bucket $b) => $this->getAvailableSpace($b, $capMethod, $allocated) > 0); while ($remaining > 0 && $eligible->isNotEmpty()) { $share = intdiv($remaining, $eligible->count()); $remainder = $remaining % $eligible->count(); $spent = 0; $filledUp = []; foreach ($eligible as $index => $bucket) { $bucketShare = $share + ($remainder > 0 ? 1 : 0); if ($remainder > 0) { $remainder--; } if ($bucketShare <= 0) { break; } $space = $this->getAvailableSpace($bucket, $capMethod, $allocated); $allocation = min($bucketShare, $space); if ($allocation > 0) { $allocated[$bucket->id] = ($allocated[$bucket->id] ?? 0) + $allocation; $spent += $allocation; } if ($allocation >= $space) { $filledUp[] = $index; } } $remaining -= $spent; if (empty($filledUp)) { // No bucket filled up, so we're done (all money placed or share was 0) break; } // Remove filled buckets and retry with remaining money $eligible = $eligible->forget($filledUp)->values(); } return $remaining; } /** * Get available space for a bucket given the cap method and already-allocated amounts. */ private function getAvailableSpace(Bucket $bucket, string $capMethod, array $allocated): int { // Non-fixed-limit buckets (percentage, unlimited) have no meaningful cap in phased distribution if (! $bucket->hasFiniteCapacity()) { return 0; } $cap = $capMethod === self::CAP_BASE ? ($bucket->allocation_value ?? 0) : $bucket->getEffectiveCapacity(); $currentBalance = $bucket->getCurrentBalance(); $alreadyAllocated = $allocated[$bucket->id] ?? 0; return max(0, $cap - $currentBalance - $alreadyAllocated); } }