empty Result. * 2. FIVE MACRO-PHASES, strictly in this order: * P1: needs -> base * P2: wants -> base * P3: needs -> buffer * P4: wants -> buffer * P5: overflow -> all remaining * A WANT's base (P2) fills BEFORE a NEED's buffer (P3) -- necessity's base * beats luxury's buffer, but luxury's base still beats necessity's buffer. * 3. WITHIN each phase, only that phase's buckets are in play (e.g. P1 only * considers type=NEED buckets), partitioned into TIERS by getPriority() * (buckets CAN share a priority -> a tier = every bucket at that priority * within the phase). Tiers are processed in ASCENDING priority order. * 4. BASE phases (P1, P2) fill each bucket toward its base cap (fixed-limit: * allocationValue; percentage: round(amount * allocationValue / 10000), * allocationValue is basis points of the ORIGINAL income, not of remaining). * 5. BUFFER phases (P3, P4) fill FIXED-LIMIT buckets only, toward * round(allocationValue * (1 + bufferMultiplier)). Percentage buckets are * skipped entirely in buffer phases (no buffer concept for them). * 6. OVERFLOW (P5): any money still remaining goes to the one OVERFLOW bucket. * 7. Leftover only flows to the NEXT tier once every tier member in the * current tier is capped; leftover only flows to the NEXT phase once every * tier in the current phase is done. * * WITHIN-TIER DISTRIBUTION when a tier can't be fully funded -- even split with * redistribute: even share = intdiv(remaining, memberCount), first * (remaining % memberCount) members (iteration order) get +1 cent. Each member * takes min(share, space). A member whose space < its share caps out and is * removed from the round; the round repeats over the rest with the leftover. * * AllocateIncome::__construct(Collection $buckets, int $income) * AllocateIncome::execute(): Result */ final class AllocateIncomeTest extends TestCase { // --- A. Guards ----------------------------------------------------- public function testNonPositiveAmountProducesEmptyDistribution(): void { $scenario = $this->makeScenario(); $bucket = $this->makeFixedLimitBucket($scenario, BucketType::NEED, priority: 1, allocationValue: 50000, name: 'Bucket'); $engine = new AllocateIncome(new ArrayCollection([$bucket]), 0); $result = $engine->execute(); self::assertInstanceOf(Result::class, $result); self::assertSame([], $result->getAllocations()); self::assertSame(0, $result->getTotalAllocated()); self::assertSame(0, $result->getUnallocated()); } public function testNoBucketsProducesEmptyDistribution(): void { $engine = new AllocateIncome(new ArrayCollection([]), 10000); $result = $engine->execute(); self::assertInstanceOf(Result::class, $result); self::assertSame([], $result->getAllocations()); self::assertSame(0, $result->getTotalAllocated()); self::assertSame(0, $result->getUnallocated()); } // --- B. Needs-base fills before wants-base, regardless of priority -- public function testNeedsBaseFillsBeforeWantsBaseRegardlessOfPriority(): void { $scenario = $this->makeScenario(); // WANT at priority 1 (numerically higher precedence), NEED at priority 2 // -- under Model C the macro-phase (type) decides before priority does, // so the NEED still fills first because needs-base (P1) precedes // wants-base (P2), even though the WANT has the "better" priority number. $want = $this->makeFixedLimitBucket($scenario, BucketType::WANT, priority: 1, allocationValue: 50000, name: 'Want'); $need = $this->makeFixedLimitBucket($scenario, BucketType::NEED, priority: 2, allocationValue: 50000, name: 'Need'); $engine = new AllocateIncome(new ArrayCollection([$need, $want]), 50000); $result = $engine->execute(); self::assertSame(50000, $this->amountFor($result, $need)); self::assertSame(0, $this->amountFor($result, $want)); self::assertSame(50000, $result->getTotalAllocated()); self::assertSame(0, $result->getUnallocated()); } // --- B2. Wants-base fills before needs-buffer ------------------------ public function testWantBaseFillsBeforeNeedBuffer(): void { $scenario = $this->makeScenario(); $need = $this->makeFixedLimitBucket($scenario, BucketType::NEED, priority: 1, allocationValue: 40000, name: 'Need', bufferMultiplier: '0.50'); $want = $this->makeFixedLimitBucket($scenario, BucketType::WANT, priority: 1, allocationValue: 40000, name: 'Want'); $engine = new AllocateIncome(new ArrayCollection([$need, $want]), 90000); // P1 needs-base: need=40000 (50000 left). P2 wants-base: want=40000 // (10000 left). P3 needs-buffer: need's buffered cap = round(40000*1.50) // = 60000, space = 20000, takes min(20000, 10000) = 10000 -> need=50000. // P4 wants-buffer: want has no buffer (0.00) -> stays 40000. $result = $engine->execute(); self::assertSame(50000, $this->amountFor($result, $need)); self::assertSame(40000, $this->amountFor($result, $want)); self::assertSame(90000, $result->getTotalAllocated()); self::assertSame(0, $result->getUnallocated()); } // --- C. Base before buffer across tiers ------------------------------ public function testAllTiersBaseFillsBeforeAnyTierBuffer(): void { $scenario = $this->makeScenario(); $p1 = $this->makeFixedLimitBucket($scenario, BucketType::NEED, priority: 1, allocationValue: 40000, name: 'P1', bufferMultiplier: '0.50'); $p2 = $this->makeFixedLimitBucket($scenario, BucketType::NEED, priority: 2, allocationValue: 40000, name: 'P2'); $engine = new AllocateIncome(new ArrayCollection([$p1, $p2]), 90000); // Base phase: p1=40000, p2=40000 (80000 used, 10000 left). // Buffer phase: p1 cap = round(40000 * 1.50) = 60000, space = 60000-40000=20000, // takes min(20000, 10000) = 10000 -> p1 = 50000. p2 has no buffer (0.00) -> stays 40000. $result = $engine->execute(); self::assertSame(50000, $this->amountFor($result, $p1)); self::assertSame(40000, $this->amountFor($result, $p2)); self::assertSame(90000, $result->getTotalAllocated()); self::assertSame(0, $result->getUnallocated()); } // --- D. Percentage bucket gets its proportional cut in BASE --------- public function testPercentageBucketGetsProportionalBaseCut(): void { $scenario = $this->makeScenario(); $pct = $this->makePercentageBucket($scenario, BucketType::NEED, priority: 1, allocationValueBasisPoints: 3000, name: 'Pct'); $overflow = $this->makeOverflowBucket($scenario, priority: 2, name: 'Overflow'); $engine = new AllocateIncome(new ArrayCollection([$pct, $overflow]), 10000); // Base cap = floor(10000 * 3000 / 10000) = 3000. $result = $engine->execute(); self::assertSame(3000, $this->amountFor($result, $pct)); self::assertSame(7000, $this->amountFor($result, $overflow)); self::assertSame(10000, $result->getTotalAllocated()); self::assertSame(0, $result->getUnallocated()); } // --- E. Percentage bucket has no buffer ------------------------------ public function testPercentageBucketIsNotToppedUpInBufferPhase(): void { $scenario = $this->makeScenario(); $pct = $this->makePercentageBucket($scenario, BucketType::NEED, priority: 1, allocationValueBasisPoints: 3000, name: 'Pct', bufferMultiplier: '1.00'); $overflow = $this->makeOverflowBucket($scenario, priority: 2, name: 'Overflow'); $engine = new AllocateIncome(new ArrayCollection([$pct, $overflow]), 10000); // Base cap = floor(10000 * 3000 / 10000) = 3000. Buffer phase skips percentage // entirely -- stays at 3000 even with bufferMultiplier=1.00 set. $result = $engine->execute(); self::assertSame(3000, $this->amountFor($result, $pct)); self::assertSame(7000, $this->amountFor($result, $overflow)); self::assertSame(10000, $result->getTotalAllocated()); self::assertSame(0, $result->getUnallocated()); } // --- F. Even split within a tied tier (fully funded) ----------------- public function testEvenSplitWithinTiedTierWhenFullyFunded(): void { $scenario = $this->makeScenario(); $a = $this->makeFixedLimitBucket($scenario, BucketType::NEED, priority: 1, allocationValue: 5000, name: 'A'); $b = $this->makeFixedLimitBucket($scenario, BucketType::NEED, priority: 1, allocationValue: 5000, name: 'B'); $engine = new AllocateIncome(new ArrayCollection([$a, $b]), 10000); $result = $engine->execute(); self::assertSame(5000, $this->amountFor($result, $a)); self::assertSame(5000, $this->amountFor($result, $b)); self::assertSame(10000, $result->getTotalAllocated()); self::assertSame(0, $result->getUnallocated()); } // --- G. Even split within a tied tier (underfunded, equal caps) ------ public function testEvenSplitWithinTiedTierWhenUnderfunded(): void { $scenario = $this->makeScenario(); $a = $this->makeFixedLimitBucket($scenario, BucketType::NEED, priority: 1, allocationValue: 5000, name: 'A'); $b = $this->makeFixedLimitBucket($scenario, BucketType::NEED, priority: 1, allocationValue: 5000, name: 'B'); $engine = new AllocateIncome(new ArrayCollection([$a, $b]), 6000); // even share = intdiv(6000, 2) = 3000 each, neither caps (space 5000 >= 3000). $result = $engine->execute(); self::assertSame(3000, $this->amountFor($result, $a)); self::assertSame(3000, $this->amountFor($result, $b)); self::assertSame(6000, $result->getTotalAllocated()); self::assertSame(0, $result->getUnallocated()); } // --- H. Even split with remainder cent ------------------------------- public function testEvenSplitGivesRemainderCentToFirstMemberInIterationOrder(): void { $scenario = $this->makeScenario(); $a = $this->makeFixedLimitBucket($scenario, BucketType::NEED, priority: 1, allocationValue: 100000, name: 'A'); $b = $this->makeFixedLimitBucket($scenario, BucketType::NEED, priority: 1, allocationValue: 100000, name: 'B'); $engine = new AllocateIncome(new ArrayCollection([$a, $b]), 1001); // even share = intdiv(1001, 2) = 500, remainder = 1 -> first member (A) gets +1. $result = $engine->execute(); self::assertSame(501, $this->amountFor($result, $a)); self::assertSame(500, $this->amountFor($result, $b)); self::assertSame(1001, $result->getTotalAllocated()); self::assertSame(0, $result->getUnallocated()); } // --- I. Redistribute when a peer caps mid-tier ----------------------- public function testSurplusFromCappedPeerRedistributesWithinTier(): void { $scenario = $this->makeScenario(); $a = $this->makeFixedLimitBucket($scenario, BucketType::NEED, priority: 1, allocationValue: 100, name: 'A'); $b = $this->makeFixedLimitBucket($scenario, BucketType::NEED, priority: 1, allocationValue: 5000, name: 'B'); $engine = new AllocateIncome(new ArrayCollection([$a, $b]), 1000); // Round 1: even share = intdiv(1000, 2) = 500 each. A's space is only 100 // (its cap) so it caps at 100 and is removed from the round; its surplus of // 400 flows back into the pot. Round 2: remaining = 900 for B alone -> B's // space (5000) covers it -> B takes all 900. $result = $engine->execute(); self::assertSame(100, $this->amountFor($result, $a)); self::assertSame(900, $this->amountFor($result, $b)); self::assertSame(1000, $result->getTotalAllocated()); self::assertSame(0, $result->getUnallocated()); } // --- J. Leftover flows to next tier only after current tier caps ---- public function testLeftoverAfterTierCapsFlowsToNextTier(): void { $scenario = $this->makeScenario(); $p1 = $this->makeFixedLimitBucket($scenario, BucketType::NEED, priority: 1, allocationValue: 3000, name: 'P1'); $p2 = $this->makeFixedLimitBucket($scenario, BucketType::NEED, priority: 2, allocationValue: 5000, name: 'P2'); $engine = new AllocateIncome(new ArrayCollection([$p1, $p2]), 7000); // Tier 1 (p1 alone) fully caps at 3000. Remainder 4000 flows to tier 2 (p2), // which has space for all of it (cap 5000 >= 4000). $result = $engine->execute(); self::assertSame(3000, $this->amountFor($result, $p1)); self::assertSame(4000, $this->amountFor($result, $p2)); self::assertSame(7000, $result->getTotalAllocated()); self::assertSame(0, $result->getUnallocated()); } // --- K. Overflow sweeps remainder ------------------------------------ public function testOverflowBucketSweepsRemainderAfterAllCapsAreFilled(): void { $scenario = $this->makeScenario(); $need = $this->makeFixedLimitBucket($scenario, BucketType::NEED, priority: 1, allocationValue: 10000, name: 'Need'); $overflow = $this->makeOverflowBucket($scenario, priority: 2, name: 'Overflow'); $engine = new AllocateIncome(new ArrayCollection([$need, $overflow]), 15000); $result = $engine->execute(); self::assertSame(10000, $this->amountFor($result, $need)); self::assertSame(5000, $this->amountFor($result, $overflow)); self::assertSame(15000, $result->getTotalAllocated()); self::assertSame(0, $result->getUnallocated()); } // --- L. No overflow bucket -> remainder unallocated ------------------ public function testRemainderIsUnallocatedWithoutAnOverflowBucket(): void { $scenario = $this->makeScenario(); $need = $this->makeFixedLimitBucket($scenario, BucketType::NEED, priority: 1, allocationValue: 10000, name: 'Need'); $engine = new AllocateIncome(new ArrayCollection([$need]), 15000); $result = $engine->execute(); self::assertSame(10000, $this->amountFor($result, $need)); self::assertSame(10000, $result->getTotalAllocated()); self::assertSame(5000, $result->getUnallocated()); } // --- M. startingAmount reduces space --------------------------------- public function testStartingAmountReducesAvailableSpace(): void { $scenario = $this->makeScenario(); $need = $this->makeFixedLimitBucket($scenario, BucketType::NEED, priority: 1, allocationValue: 50000, name: 'Need', startingAmount: 45000); $engine = new AllocateIncome(new ArrayCollection([$need]), 20000); // space = max(0, 50000 - 45000 - 0) = 5000. No overflow bucket -> rest unallocated. $result = $engine->execute(); self::assertSame(5000, $this->amountFor($result, $need)); self::assertSame(5000, $result->getTotalAllocated()); self::assertSame(15000, $result->getUnallocated()); } // --- N. Rounding pinned ----------------------------------------------- public function testBufferedCapUsesRoundedIntegerCents(): void { $scenario = $this->makeScenario(); $need = $this->makeFixedLimitBucket($scenario, BucketType::NEED, priority: 1, allocationValue: 10001, name: 'Need', bufferMultiplier: '0.33'); $engine = new AllocateIncome(new ArrayCollection([$need]), 20000); // bufferedCap = (int) round(10001 * 1.33) = (int) round(13301.33) = 13301. $result = $engine->execute(); self::assertSame(13301, $this->amountFor($result, $need)); self::assertIsInt($this->amountFor($result, $need)); self::assertSame(13301, $result->getTotalAllocated()); self::assertSame(6699, $result->getUnallocated()); } // --- O. Priority tiers within a phase (gap coverage) ----------------- public function testHigherPriorityTierFillsBeforeLowerPriorityTier(): void { $scenario = $this->makeScenario(); $p1 = $this->makeFixedLimitBucket($scenario, BucketType::NEED, priority: 1, allocationValue: 10000, name: 'P1'); $p2 = $this->makeFixedLimitBucket($scenario, BucketType::NEED, priority: 2, allocationValue: 10000, name: 'P2'); $p3 = $this->makeFixedLimitBucket($scenario, BucketType::NEED, priority: 3, allocationValue: 10000, name: 'P3'); $engine = new AllocateIncome(new ArrayCollection([$p1, $p2, $p3]), 15000); // Tier 1 (priority 1) fills fully to its cap (10000), leaving 5000. // Tier 2 (priority 2) takes the remaining 5000 (under its 10000 cap). // Tier 3 (priority 3) gets nothing -- nothing left to flow to it. $result = $engine->execute(); self::assertSame(10000, $this->amountFor($result, $p1)); self::assertSame(5000, $this->amountFor($result, $p2)); self::assertSame(0, $this->amountFor($result, $p3)); self::assertSame(15000, $result->getTotalAllocated()); self::assertSame(0, $result->getUnallocated()); } public function testSamePriorityBucketsShareOneTierEvenly(): void { $scenario = $this->makeScenario(); $a = $this->makeFixedLimitBucket($scenario, BucketType::NEED, priority: 1, allocationValue: 10000, name: 'A'); $b = $this->makeFixedLimitBucket($scenario, BucketType::NEED, priority: 1, allocationValue: 10000, name: 'B'); $c = $this->makeFixedLimitBucket($scenario, BucketType::NEED, priority: 2, allocationValue: 10000, name: 'C'); $engine = new AllocateIncome(new ArrayCollection([$a, $b, $c]), 12000); // Tier 1 (A and B, same priority) splits the 12000 evenly: 6000 each, // both well under their 10000 cap -- tier 1 absorbs the whole amount. // Tier 2 (C) gets nothing left over. $result = $engine->execute(); self::assertSame(6000, $this->amountFor($result, $a)); self::assertSame(6000, $this->amountFor($result, $b)); self::assertSame(0, $this->amountFor($result, $c)); self::assertSame(12000, $result->getTotalAllocated()); self::assertSame(0, $result->getUnallocated()); } public function testLowerPriorityTierGetsRemainderAfterHigherTierCaps(): void { $scenario = $this->makeScenario(); $p1 = $this->makeFixedLimitBucket($scenario, BucketType::NEED, priority: 1, allocationValue: 4000, name: 'P1'); $p2 = $this->makeFixedLimitBucket($scenario, BucketType::NEED, priority: 2, allocationValue: 10000, name: 'P2'); $engine = new AllocateIncome(new ArrayCollection([$p1, $p2]), 9000); // Tier 1 (P1) caps out at 4000. Remaining 5000 flows to tier 2 (P2), // which has room (cap 10000 >= 5000). $result = $engine->execute(); self::assertSame(4000, $this->amountFor($result, $p1)); self::assertSame(5000, $this->amountFor($result, $p2)); self::assertSame(9000, $result->getTotalAllocated()); self::assertSame(0, $result->getUnallocated()); } public function testPriorityTiersAlsoApplyWithinBufferPhase(): void { $scenario = $this->makeScenario(); $p1 = $this->makeFixedLimitBucket($scenario, BucketType::NEED, priority: 1, allocationValue: 4000, name: 'P1', bufferMultiplier: '1.00'); $p2 = $this->makeFixedLimitBucket($scenario, BucketType::NEED, priority: 2, allocationValue: 4000, name: 'P2', bufferMultiplier: '1.00'); $engine = new AllocateIncome(new ArrayCollection([$p1, $p2]), 14000); // Base phase, tier 1 (P1) then tier 2 (P2): both fill their base cap of // 4000 (8000 used, 6000 left). // Buffer phase, tier 1 (P1) first: buffered cap = round(4000*2.00) = 8000, // space = 8000-4000 = 4000, takes min(4000, 6000) = 4000 -> P1 = 8000 // (2000 left). Tier 2 (P2): buffered cap 8000, space 4000, takes // min(4000, 2000) = 2000 -> P2 = 6000. $result = $engine->execute(); self::assertSame(8000, $this->amountFor($result, $p1)); self::assertSame(6000, $this->amountFor($result, $p2)); self::assertSame(14000, $result->getTotalAllocated()); self::assertSame(0, $result->getUnallocated()); } // --- P. Percentage cap must use ORIGINAL income, not the remainder --- public function testPercentageBucketCapIsBasedOnOriginalIncomeNotRemaining(): void { $scenario = $this->makeScenario(); $fixed = $this->makeFixedLimitBucket($scenario, BucketType::NEED, priority: 1, allocationValue: 4000, name: 'Fixed'); $pct = $this->makePercentageBucket($scenario, BucketType::NEED, priority: 2, allocationValueBasisPoints: 3000, name: 'Pct'); $overflow = $this->makeOverflowBucket($scenario, priority: 3, name: 'Overflow'); $engine = new AllocateIncome(new ArrayCollection([$fixed, $pct, $overflow]), 10000); // Tier 1 (priority 1, fixed): caps at 4000. Remaining = 6000. // Tier 2 (priority 2, percentage): cap MUST be floor(10000 * 3000 / 10000) // = 3000 -- based on the ORIGINAL income (10000), NOT the dwindled // remaining (6000). It takes 3000. Remaining = 3000. // Overflow sweeps the rest: 3000. $result = $engine->execute(); self::assertSame(4000, $this->amountFor($result, $fixed)); self::assertSame(3000, $this->amountFor($result, $pct)); self::assertSame(3000, $this->amountFor($result, $overflow)); self::assertSame(10000, $result->getTotalAllocated()); self::assertSame(0, $result->getUnallocated()); } // --- fixtures --------------------------------------------------------- private function makeScenario(): Scenario { return (new Scenario()) ->setName('Household Budget') ->setDistributionMode(DistributionMode::PRIORITY); } private function makeFixedLimitBucket( Scenario $scenario, BucketType $type, int $priority, int $allocationValue, string $name, string $bufferMultiplier = '0.00', int $startingAmount = 0, ): Bucket { return (new Bucket()) ->setScenario($scenario) ->setType($type) ->setName($name) ->setPriority($priority) ->setAllocationType(BucketAllocationType::FIXED_LIMIT) ->setAllocationValue($allocationValue) ->setStartingAmount($startingAmount) ->setBufferMultiplier($bufferMultiplier); } private function makePercentageBucket( Scenario $scenario, BucketType $type, int $priority, int $allocationValueBasisPoints, string $name, string $bufferMultiplier = '0.00', int $startingAmount = 0, ): Bucket { return (new Bucket()) ->setScenario($scenario) ->setType($type) ->setName($name) ->setPriority($priority) ->setAllocationType(BucketAllocationType::PERCENTAGE) ->setAllocationValue($allocationValueBasisPoints) ->setStartingAmount($startingAmount) ->setBufferMultiplier($bufferMultiplier); } private function makeOverflowBucket(Scenario $scenario, int $priority, string $name): Bucket { return (new Bucket()) ->setScenario($scenario) ->setType(BucketType::OVERFLOW) ->setName($name) ->setPriority($priority) ->setAllocationType(BucketAllocationType::UNLIMITED) ->setAllocationValue(null) ->setStartingAmount(0) ->setBufferMultiplier('0.00'); } /** * @param Result $result */ private function amountFor($result, Bucket $bucket): int { foreach ($result->getAllocations() as $allocation) { if ($allocation['bucket'] === $bucket) { return $allocation['amount']; } } return 0; } }