diff --git a/domain/src/Aggregates/SharePoolingAsset/Entities/Exceptions/SharePoolingAssetAcquisitionException.php b/domain/src/Aggregates/SharePoolingAsset/Entities/Exceptions/SharePoolingAssetAcquisitionException.php index 6ebb4f3..a6f6910 100644 --- a/domain/src/Aggregates/SharePoolingAsset/Entities/Exceptions/SharePoolingAssetAcquisitionException.php +++ b/domain/src/Aggregates/SharePoolingAsset/Entities/Exceptions/SharePoolingAssetAcquisitionException.php @@ -19,21 +19,34 @@ public static function excessiveQuantityAllocated(Quantity $available, Quantity return new self(sprintf('The allocated quantity %s exceeds the available quantity %s', $allocated, $available)); } - public static function insufficientSameDayQuantity(Quantity $quantity, Quantity $sameDayQuantity): self + public static function insufficientSameDayQuantityToIncrease(Quantity $quantity, Quantity $availableQuantity): self { - return new self(sprintf( - 'Cannot decrease same-day quantity by %s: only %s available', - $quantity, - $sameDayQuantity, - )); + return self::insufficientQuantity($quantity, $availableQuantity, 'same-day', 'increase'); + } + + public static function insufficientSameDayQuantityToDecrease(Quantity $quantity, Quantity $availableQuantity): self + { + return self::insufficientQuantity($quantity, $availableQuantity, 'same-day', 'decrease'); + } + + public static function insufficientThirtyDayQuantityToIncrease(Quantity $quantity, Quantity $availableQuantity): self + { + return self::insufficientQuantity($quantity, $availableQuantity, '30-day', 'increase'); + } + + public static function insufficientThirtyDayQuantityToDecrease(Quantity $quantity, Quantity $availableQuantity): self + { + return self::insufficientQuantity($quantity, $availableQuantity, '30-day', 'decrease'); } - public static function insufficientThirtyDayQuantity(Quantity $quantity, Quantity $thirtyDayQuantity): self + private static function insufficientQuantity(Quantity $quantity, Quantity $availableQuantity, string $type, string $action): self { return new self(sprintf( - 'Cannot decrease 30-day quantity by %s: only %s available', + 'Cannot %s %s quantity by %s: only %s available', + $action, + $type, $quantity, - $thirtyDayQuantity, + $availableQuantity, )); } } diff --git a/domain/src/Aggregates/SharePoolingAsset/Entities/SharePoolingAssetAcquisition.php b/domain/src/Aggregates/SharePoolingAsset/Entities/SharePoolingAssetAcquisition.php index 49d46e3..10673b3 100644 --- a/domain/src/Aggregates/SharePoolingAsset/Entities/SharePoolingAssetAcquisition.php +++ b/domain/src/Aggregates/SharePoolingAsset/Entities/SharePoolingAssetAcquisition.php @@ -63,8 +63,23 @@ public function section104PoolCostBasis(): FiatAmount return $this->costBasis->dividedBy($this->quantity)->multipliedBy($this->section104PoolQuantity()); } - /** Increase the same-day quantity and adjust the 30-day quantity accordingly. */ public function increaseSameDayQuantity(Quantity $quantity): self + { + if ($quantity->isGreaterThan($availableQuantity = $this->section104PoolQuantity())) { + throw SharePoolingAssetAcquisitionException::insufficientSameDayQuantityToIncrease($quantity, $availableQuantity); + } + + $this->sameDayQuantity = $this->sameDayQuantity->plus($quantity); + + return $this; + } + + /** + * Increase the same-day quantity and adjust the 30-day quantity accordingly. + * + * @return Quantity the added quantity + */ + public function increaseSameDayQuantityUpToAvailableQuantity(Quantity $quantity): Quantity { // Adjust same-day quantity $quantityToAdd = Quantity::minimum($quantity, $this->availableSameDayQuantity()); @@ -74,14 +89,14 @@ public function increaseSameDayQuantity(Quantity $quantity): self $quantityToDeduct = Quantity::minimum($quantityToAdd, $this->thirtyDayQuantity); $this->thirtyDayQuantity = $this->thirtyDayQuantity->minus($quantityToDeduct); - return $this; + return $quantityToAdd; } /** @throws SharePoolingAssetAcquisitionException */ public function decreaseSameDayQuantity(Quantity $quantity): self { if ($quantity->isGreaterThan($this->sameDayQuantity)) { - throw SharePoolingAssetAcquisitionException::insufficientSameDayQuantity($quantity, $this->sameDayQuantity); + throw SharePoolingAssetAcquisitionException::insufficientSameDayQuantityToDecrease($quantity, $this->sameDayQuantity); } $this->sameDayQuantity = $this->sameDayQuantity->minus($quantity); @@ -90,18 +105,30 @@ public function decreaseSameDayQuantity(Quantity $quantity): self } public function increaseThirtyDayQuantity(Quantity $quantity): self + { + if ($quantity->isGreaterThan($availableQuantity = $this->section104PoolQuantity())) { + throw SharePoolingAssetAcquisitionException::insufficientThirtyDayQuantityToIncrease($quantity, $availableQuantity); + } + + $this->thirtyDayQuantity = $this->thirtyDayQuantity->plus($quantity); + + return $this; + } + + /** @return Quantity the added quantity */ + public function increaseThirtyDayQuantityUpToAvailableQuantity(Quantity $quantity): Quantity { $quantityToAdd = Quantity::minimum($quantity, $this->availableThirtyDayQuantity()); $this->thirtyDayQuantity = $this->thirtyDayQuantity->plus($quantityToAdd); - return $this; + return $quantityToAdd; } /** @throws SharePoolingAssetAcquisitionException */ public function decreaseThirtyDayQuantity(Quantity $quantity): self { if ($quantity->isGreaterThan($this->thirtyDayQuantity)) { - throw SharePoolingAssetAcquisitionException::insufficientThirtyDayQuantity($quantity, $this->thirtyDayQuantity); + throw SharePoolingAssetAcquisitionException::insufficientThirtyDayQuantityToDecrease($quantity, $this->thirtyDayQuantity); } $this->thirtyDayQuantity = $this->thirtyDayQuantity->minus($quantity); diff --git a/domain/src/Aggregates/SharePoolingAsset/Entities/SharePoolingAssetDisposals.php b/domain/src/Aggregates/SharePoolingAsset/Entities/SharePoolingAssetDisposals.php index 42ae8c0..6d36960 100644 --- a/domain/src/Aggregates/SharePoolingAsset/Entities/SharePoolingAssetDisposals.php +++ b/domain/src/Aggregates/SharePoolingAsset/Entities/SharePoolingAssetDisposals.php @@ -65,16 +65,6 @@ public function reverse(): SharePoolingAssetDisposals return new self(array_reverse($this->disposals)); } - public function unprocessed(): SharePoolingAssetDisposals - { - $disposals = array_filter( - $this->disposals, - fn (SharePoolingAssetDisposal $disposal) => ! $disposal->processed, - ); - - return self::make(...$disposals); - } - public function withAvailableSameDayQuantity(): SharePoolingAssetDisposals { $disposals = array_filter( diff --git a/domain/src/Aggregates/SharePoolingAsset/Entities/SharePoolingAssetTransaction.php b/domain/src/Aggregates/SharePoolingAsset/Entities/SharePoolingAssetTransaction.php index 2ce866c..51d29d4 100644 --- a/domain/src/Aggregates/SharePoolingAsset/Entities/SharePoolingAssetTransaction.php +++ b/domain/src/Aggregates/SharePoolingAsset/Entities/SharePoolingAssetTransaction.php @@ -51,8 +51,11 @@ public function hasAvailableSameDayQuantity(): bool return $this->availableSameDayQuantity()->isGreaterThan('0'); } + /** Return the quantity that is not yet allocated as same-day quantity. */ public function availableSameDayQuantity(): Quantity { + // Same-day quantity always gets priority, so any quantity that is not yet + // allocated as same-day quantity is technically available as same-day quantity return $this->quantity->minus($this->sameDayQuantity()); } @@ -64,7 +67,7 @@ public function hasAvailableThirtyDayQuantity(): bool public function availableThirtyDayQuantity(): Quantity { // Same-day quantity always gets priority, and it is assumed that the existing - // 30-day quantity has already been matched with priority transactions. That + // 30-day quantity has already been allocated to priority transactions. That // leaves us with the section 104 pool quantity, which is what we return return $this->section104PoolQuantity(); } diff --git a/domain/src/Aggregates/SharePoolingAsset/Entities/SharePoolingAssetTransactions.php b/domain/src/Aggregates/SharePoolingAsset/Entities/SharePoolingAssetTransactions.php index cafc531..b83241d 100644 --- a/domain/src/Aggregates/SharePoolingAsset/Entities/SharePoolingAssetTransactions.php +++ b/domain/src/Aggregates/SharePoolingAsset/Entities/SharePoolingAssetTransactions.php @@ -63,6 +63,11 @@ public function count(): int return count($this->transactions); } + public function copy(): self + { + return new self(array_map(fn (SharePoolingAssetTransaction $transaction) => clone $transaction, $this->transactions)); + } + public function first(): ?SharePoolingAssetTransaction { return $this->transactions[0] ?? null; @@ -267,7 +272,7 @@ public function disposalsMadeAfterOrOn(LocalDate $date): SharePoolingAssetDispos return $this->disposalsMadeAfter($date->minusDays(1)); } - public function disposalsWithThirtyDayQuantityMatchedWith(SharePoolingAssetAcquisition $acquisition): SharePoolingAssetDisposals + public function disposalsWithThirtyDayQuantityAllocatedTo(SharePoolingAssetAcquisition $acquisition): SharePoolingAssetDisposals { $transactions = array_filter( $this->transactions, diff --git a/domain/src/Aggregates/SharePoolingAsset/Services/DisposalProcessor/DisposalProcessor.php b/domain/src/Aggregates/SharePoolingAsset/Services/DisposalBuilder/DisposalBuilder.php similarity index 78% rename from domain/src/Aggregates/SharePoolingAsset/Services/DisposalProcessor/DisposalProcessor.php rename to domain/src/Aggregates/SharePoolingAsset/Services/DisposalBuilder/DisposalBuilder.php index 3bdc346..d44d3b3 100644 --- a/domain/src/Aggregates/SharePoolingAsset/Services/DisposalProcessor/DisposalProcessor.php +++ b/domain/src/Aggregates/SharePoolingAsset/Services/DisposalBuilder/DisposalBuilder.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Domain\Aggregates\SharePoolingAsset\Services\DisposalProcessor; +namespace Domain\Aggregates\SharePoolingAsset\Services\DisposalBuilder; use Brick\DateTime\LocalDate; use Domain\Aggregates\SharePoolingAsset\Actions\DisposeOfSharePoolingAsset; @@ -16,9 +16,9 @@ * This service essentially calculates the cost basis of a disposal by looking at past and future * transactions, following the various share pooling asset rules (same-day, 30-day, section 104 pool). */ -final class DisposalProcessor +final class DisposalBuilder { - public static function process( + public static function make( DisposeOfSharePoolingAsset $disposal, SharePoolingAssetTransactions $transactions, ): SharePoolingAssetDisposal { @@ -107,11 +107,10 @@ private static function processSameDayAcquisitions( // Deduct the applied quantity from the same-day acquisitions $remainder = $availableSameDayQuantity; foreach ($sameDayAcquisitions as $acquisition) { - $quantityToAllocate = Quantity::minimum($remainder, $acquisition->availableSameDayQuantity()); + $quantityToAllocate = $acquisition->increaseSameDayQuantityUpToAvailableQuantity($remainder); $sameDayQuantityAllocation->allocateQuantity($quantityToAllocate, $acquisition); - $acquisition->increaseSameDayQuantity($remainder); - $remainder = $remainder->minus($quantityToAllocate); - if ($remainder->isZero()) { + + if (($remainder = $remainder->minus($quantityToAllocate))->isZero()) { break; } } @@ -144,37 +143,13 @@ private static function processAcquisitionsWithinThirtyDays( foreach ($withinThirtyDaysAcquisitions as $acquisition) { // Apply the acquisition's cost basis to the disposed of asset up to the remaining quantity - $thirtyDayQuantityToApply = Quantity::minimum($acquisition->availableThirtyDayQuantity(), $remainingQuantity); - - // Also deduct same-day disposals with available same-day quantity that haven't been processed yet - $sameDayDisposals = $transactions->disposalsMadeOn($acquisition->date) - ->unprocessed() - ->withAvailableSameDayQuantity(); - - foreach ($sameDayDisposals as $disposal) { - $sameDayQuantityToApply = Quantity::minimum($disposal->availableSameDayQuantity(), $thirtyDayQuantityToApply); - $disposal->sameDayQuantityAllocation->allocateQuantity($sameDayQuantityToApply, $acquisition); - $acquisition->increaseSameDayQuantity($sameDayQuantityToApply); - $thirtyDayQuantityToApply = $thirtyDayQuantityToApply->minus($sameDayQuantityToApply); - if ($thirtyDayQuantityToApply->isZero()) { - break; - } - } - - if ($thirtyDayQuantityToApply->isZero()) { - continue; - } - + $quantityToAllocate = $acquisition->increaseThirtyDayQuantityUpToAvailableQuantity($remainingQuantity); $averageCostBasisPerUnit = $acquisition->averageCostBasisPerUnit(); + $costBasis = $costBasis->plus($averageCostBasisPerUnit->multipliedBy($quantityToAllocate)); + $thirtyDayQuantityAllocation->allocateQuantity($quantityToAllocate, $acquisition); - $costBasis = $costBasis->plus($averageCostBasisPerUnit->multipliedBy($thirtyDayQuantityToApply)); - - $thirtyDayQuantityAllocation->allocateQuantity($thirtyDayQuantityToApply, $acquisition); - $acquisition->increaseThirtyDayQuantity($thirtyDayQuantityToApply); - $remainingQuantity = $remainingQuantity->minus($thirtyDayQuantityToApply); - - // Continue until there are no more transactions or we've covered all disposed tokens - if ($remainingQuantity->isZero()) { + // Continue until there are no more transactions or we've covered all disposed of tokens + if (($remainingQuantity = $remainingQuantity->minus($quantityToAllocate))->isZero()) { break; } } diff --git a/domain/src/Aggregates/SharePoolingAsset/Services/QuantityAdjuster/QuantityAdjuster.php b/domain/src/Aggregates/SharePoolingAsset/Services/QuantityAdjuster/QuantityAdjuster.php index 9150498..3012a50 100644 --- a/domain/src/Aggregates/SharePoolingAsset/Services/QuantityAdjuster/QuantityAdjuster.php +++ b/domain/src/Aggregates/SharePoolingAsset/Services/QuantityAdjuster/QuantityAdjuster.php @@ -12,24 +12,37 @@ use Domain\Aggregates\SharePoolingAsset\Services\QuantityAdjuster\Exceptions\QuantityAdjusterException; use Domain\Aggregates\SharePoolingAsset\ValueObjects\QuantityAllocation; -/** - * This service restores the quantities from acquisitions that were - * previously matched with a disposal that is now being reverted. - */ final class QuantityAdjuster { + /** + * Adjust acquisition quantities based on the disposal's allocated quantities. + * + * @throws SharePoolingAssetAcquisitionException + */ + public static function applyDisposal( + SharePoolingAssetDisposal $disposal, + SharePoolingAssetTransactions $transactions, + ): void { + foreach (self::getAcquisitions($disposal->sameDayQuantityAllocation, $transactions) as $acquisition) { + $acquisition->increaseSameDayQuantity($disposal->sameDayQuantityAllocation->quantityAllocatedTo($acquisition)); + } + + foreach (self::getAcquisitions($disposal->thirtyDayQuantityAllocation, $transactions) as $acquisition) { + $acquisition->increaseThirtyDayQuantity($disposal->thirtyDayQuantityAllocation->quantityAllocatedTo($acquisition)); + } + } + + /** + * Restore acquisition quantities that were previously allocated to a disposal that is now being reverted. + * + * @throws SharePoolingAssetAcquisitionException + */ public static function revertDisposal( SharePoolingAssetDisposal $disposal, SharePoolingAssetTransactions $transactions, ): void { foreach (self::getAcquisitions($disposal->sameDayQuantityAllocation, $transactions) as $acquisition) { - try { - $acquisition->decreaseSameDayQuantity($disposal->sameDayQuantityAllocation->quantityAllocatedTo($acquisition)); - } catch (SharePoolingAssetAcquisitionException) { - // @TODO When re-acquiring within 30 days an asset that was disposed of on the same day it was acquired, - // decreasing the same-day quantity of the concerned acquisitions fails, because at the time the latter - // were recorded within the SharePoolingAssetAcquired events that had no same-day quantity yet - } + $acquisition->decreaseSameDayQuantity($disposal->sameDayQuantityAllocation->quantityAllocatedTo($acquisition)); } foreach (self::getAcquisitions($disposal->thirtyDayQuantityAllocation, $transactions) as $acquisition) { diff --git a/domain/src/Aggregates/SharePoolingAsset/Services/ReversionFinder/ReversionFinder.php b/domain/src/Aggregates/SharePoolingAsset/Services/ReversionFinder/ReversionFinder.php index 81bbb18..7d2a24e 100644 --- a/domain/src/Aggregates/SharePoolingAsset/Services/ReversionFinder/ReversionFinder.php +++ b/domain/src/Aggregates/SharePoolingAsset/Services/ReversionFinder/ReversionFinder.php @@ -38,19 +38,20 @@ private static function addSameDayDisposalsToRevert( return $disposalsToRevert; } - // Get same-day disposals with part of their quantity not matched with same-day acquisitions - $sameDayDisposals = $transactions->disposalsMadeOn($date)->withAvailableSameDayQuantity(); + // Get all same-day disposals. As the average cost basis of same-day acquisitions is used to + // calculate the cost basis of the disposals, it's simpler to revert them all and start over + $sameDayDisposals = $transactions->disposalsMadeOn($date); if ($sameDayDisposals->isEmpty()) { return self::add30DayDisposalsToRevert($disposalsToRevert, $transactions, $date, $remainingQuantity); } - // As the average cost basis of same-day acquisitions is used to calculate the - // cost basis of the disposals, it's simpler to revert them all and start over $disposalsToRevert->add(...$sameDayDisposals); - // Deduct what's left (either the whole remaining quantity or the disposals' unmatched - // same-day quantity, whichever is smaller) from the remaining quantity to be matched + // Deduct either the acquisition's remaining quantity or the disposals' unallocated same-day + // quantity (whichever is smaller) from the quantity to be allocated. The trick here is that, + // while we are going to revert and replay all same-day disposals, their same-day quantity + // already allocated to earlier same-day acquisitions must be taken into account, still $quantityToDeduct = Quantity::minimum($remainingQuantity, $sameDayDisposals->availableSameDayQuantity()); $remainingQuantity = $remainingQuantity->minus($quantityToDeduct); @@ -74,8 +75,8 @@ private static function add30DayDisposalsToRevert( foreach ($pastThirtyDaysDisposals as $disposal) { $disposalsToRevert->add($disposal); - // Deduct what's left (either the whole remaining quantity or the disposal's available - // 30-day quantity, whichever is smaller) from the remaining quantity to be matched + // Deduct either the acquisition's remaining quantity or the disposal's uncallocated + // 30-day quantity (whichever is smaller) from the quantity to be allocated $quantityToDeduct = Quantity::minimum($remainingQuantity, $disposal->availableThirtyDayQuantity()); $remainingQuantity = $remainingQuantity->minus($quantityToDeduct); @@ -88,36 +89,43 @@ private static function add30DayDisposalsToRevert( return $disposalsToRevert; } + /** + * Get processed disposals with 30-day quantity allocated to acquisitions from the same day as the + * current disposal, with same-day quantity that should be allocated to that disposal instead. + */ public static function disposalsToRevertOnDisposal( DisposeOfSharePoolingAsset $disposal, SharePoolingAssetTransactions $transactions, ): SharePoolingAssetDisposals { $disposalsToRevert = SharePoolingAssetDisposals::make(); - // Get processed disposals with 30-day quantity matched with acquisitions on the same - // day as the disposal, with same-day quantity about to be matched with the disposal + // Get the acquisitions from the same day as the disposal and with currently-allocated 30-day quantity $sameDayAcquisitions = $transactions->acquisitionsMadeOn($disposal->date)->withThirtyDayQuantity(); $remainingQuantity = $disposal->quantity; foreach ($sameDayAcquisitions as $acquisition) { - // Add disposals up to the disposal's quantity, starting with the most recent ones - $disposalsWithMatchedThirtyDayQuantity = $transactions->processed() - ->disposalsWithThirtyDayQuantityMatchedWith($acquisition) + // Add disposals up to the disposal's quantity, starting with the most recent ones. That is + // because older disposals get priority when allocating the 30-day quantity of acquisitions + // made within 30 days of the disposal, so the last disposals in are the first out + $disposalsWithAllocatedThirtyDayQuantity = $transactions + ->disposalsWithThirtyDayQuantityAllocatedTo($acquisition) ->reverse(); - foreach ($disposalsWithMatchedThirtyDayQuantity as $disposal) { + foreach ($disposalsWithAllocatedThirtyDayQuantity as $disposal) { $disposalsToRevert->add($disposal); $quantityToDeduct = Quantity::minimum($disposal->thirtyDayQuantityAllocatedTo($acquisition), $remainingQuantity); $remainingQuantity = $remainingQuantity->minus($quantityToDeduct); - // Stop as soon as the disposal's quantity has fully been allocated + // Stop as soon as the disposal's quantity has been fully allocated if ($remainingQuantity->isZero()) { break 2; } } } - return $disposalsToRevert; + // To maintain the priority of older disposals over the 30-day quantity of acquisitions made + // within 30 days, however, they need to be reverted and replayed in chronological order + return $disposalsToRevert->reverse(); } } diff --git a/domain/src/Aggregates/SharePoolingAsset/SharePoolingAsset.php b/domain/src/Aggregates/SharePoolingAsset/SharePoolingAsset.php index 183f7ec..78c3653 100644 --- a/domain/src/Aggregates/SharePoolingAsset/SharePoolingAsset.php +++ b/domain/src/Aggregates/SharePoolingAsset/SharePoolingAsset.php @@ -10,7 +10,6 @@ use Domain\Aggregates\SharePoolingAsset\Actions\Contracts\WithAsset; use Domain\Aggregates\SharePoolingAsset\Actions\DisposeOfSharePoolingAsset; use Domain\Aggregates\SharePoolingAsset\Entities\SharePoolingAssetAcquisition; -use Domain\Aggregates\SharePoolingAsset\Entities\SharePoolingAssetDisposal; use Domain\Aggregates\SharePoolingAsset\Entities\SharePoolingAssetDisposals; use Domain\Aggregates\SharePoolingAsset\Entities\SharePoolingAssetTransactions; use Domain\Aggregates\SharePoolingAsset\Events\SharePoolingAssetAcquired; @@ -18,7 +17,7 @@ use Domain\Aggregates\SharePoolingAsset\Events\SharePoolingAssetDisposedOf; use Domain\Aggregates\SharePoolingAsset\Events\SharePoolingAssetFiatCurrencySet; use Domain\Aggregates\SharePoolingAsset\Exceptions\SharePoolingAssetException; -use Domain\Aggregates\SharePoolingAsset\Services\DisposalProcessor\DisposalProcessor; +use Domain\Aggregates\SharePoolingAsset\Services\DisposalBuilder\DisposalBuilder; use Domain\Aggregates\SharePoolingAsset\Services\QuantityAdjuster\QuantityAdjuster; use Domain\Aggregates\SharePoolingAsset\Services\ReversionFinder\ReversionFinder; use Domain\Aggregates\SharePoolingAsset\ValueObjects\SharePoolingAssetId; @@ -70,7 +69,7 @@ public function acquire(AcquireSharePoolingAsset $action): void transactions: $this->transactions, ); - $this->revertDisposals($disposalsToRevert); + $disposalsToRevert->isEmpty() || $this->revertDisposals($disposalsToRevert); // Record the new acquisition $this->recordThat(new SharePoolingAssetAcquired( @@ -83,13 +82,22 @@ public function acquire(AcquireSharePoolingAsset $action): void ), )); - $this->replayDisposals($disposalsToRevert); + $disposalsToRevert->isEmpty() || $this->replayDisposals($disposalsToRevert); } public function applySharePoolingAssetAcquired(SharePoolingAssetAcquired $event): void { $this->previousTransactionDate = $event->acquisition->date; - $this->transactions->add($event->acquisition); + // The reason for cloning here is for cases where an acquisition causes some disposals to be reverted before + // the acquisition is recorded and the disposals subsequently replayed. The acquisition should be recorded + // with its same-day and 30-day quantities to zero, because at the time of the event the disposals haven't + // been replayed yet. But the aggregate is only persisted after the disposals have been reverted, the + // acquisition has been processed *and* the disposals have been replayed. Since the latter update the + // acquisition's same-day and 30-day quantities, these updates occur before the acquisition's event + // is recorded, meaning the event is stored with the updated quantities. As a result, whenever the + // aggregate is recreated from its events, the acquisition already has a same-day and/or 30-day + // quantity, but upon replaying the subsequent disposals, these quantities are updated *again* + $this->transactions->add(clone $event->acquisition); } /** @throws SharePoolingAssetException */ @@ -97,9 +105,7 @@ public function disposeOf(DisposeOfSharePoolingAsset $action): void { $this->validateCurrency($action->proceeds->currency, $action); - if (! $action->isReplay()) { - $this->validateTimeline($action); - } + $action->isReplay() || $this->validateTimeline($action); $this->validateDisposalQuantity($action); @@ -108,30 +114,16 @@ public function disposeOf(DisposeOfSharePoolingAsset $action): void transactions: $this->transactions, ); - // If there are no disposals to revert, process the current disposal normally - if ($disposalsToRevert->isEmpty()) { - $this->recordDisposal($action); + $disposalsToRevert->isEmpty() || $this->revertDisposals($disposalsToRevert); - return; - } - - $this->revertDisposals($disposalsToRevert); - - // Add the current disposal to the transactions (as unprocessed) so previous disposals - // don't try to match their 30-day quantity with the disposal's same-day acquisitions - $this->transactions->add(new SharePoolingAssetDisposal( - id: $action->transactionId, - date: $action->date, - quantity: $action->quantity, - costBasis: $action->proceeds->zero(), - proceeds: $action->proceeds, - forFiat: $action->forFiat, - processed: false, - )); + $sharePoolingAssetDisposal = DisposalBuilder::make( + disposal: $action, + transactions: $this->transactions->copy(), + ); - $this->replayDisposals($disposalsToRevert); + $this->recordThat(new SharePoolingAssetDisposedOf(disposal: $sharePoolingAssetDisposal)); - $this->recordDisposal($action); + $disposalsToRevert->isEmpty() || $this->replayDisposals($disposalsToRevert); } private function replayDisposals(SharePoolingAssetDisposals $disposals): void @@ -148,18 +140,11 @@ private function replayDisposals(SharePoolingAssetDisposals $disposals): void } } - private function recordDisposal(DisposeOfSharePoolingAsset $action): void - { - $sharePoolingAssetDisposal = DisposalProcessor::process( - disposal: $action, - transactions: $this->transactions, - ); - - $this->recordThat(new SharePoolingAssetDisposedOf(disposal: $sharePoolingAssetDisposal)); - } - public function applySharePoolingAssetDisposedOf(SharePoolingAssetDisposedOf $event): void { + // Adjust quantities for acquisitions whose quantities were allocated to the disposal + QuantityAdjuster::applyDisposal($event->disposal, $this->transactions); + $this->previousTransactionDate = $event->disposal->date; $this->transactions->add($event->disposal); } @@ -167,18 +152,17 @@ public function applySharePoolingAssetDisposedOf(SharePoolingAssetDisposedOf $ev private function revertDisposals(SharePoolingAssetDisposals $disposals): void { foreach ($disposals as $disposal) { - // Restore quantities deducted from the acquisitions whose quantities were allocated to the disposal - QuantityAdjuster::revertDisposal($disposal, $this->transactions); - $this->recordThat(new SharePoolingAssetDisposalReverted(disposal: $disposal)); } } public function applySharePoolingAssetDisposalReverted(SharePoolingAssetDisposalReverted $event): void { - // Replace the disposal in the array with the same disposal, but with reset quantities. This - // way, when several disposals are being replayed, a disposal won't be matched with future - // acquisitions within the next 30 days if these acquisitions have disposals on the same day + // Restore quantities deducted from acquisitions whose quantities were allocated to the disposal + QuantityAdjuster::revertDisposal($event->disposal, $this->transactions); + + // Replace the disposal in the array with the same disposal, as unprocessed. This way we save the disposal's + // position in the array but it's ignored for calculations (e.g. the validation of the disposed of quantity) $this->transactions->add($event->disposal->copyAsUnprocessed()); } diff --git a/domain/tests/Aggregates/SharePoolingAsset/Entities/SharePoolingAssetAcquisitionTest.php b/domain/tests/Aggregates/SharePoolingAsset/Entities/SharePoolingAssetAcquisitionTest.php index 7a6fcc0..dc00334 100644 --- a/domain/tests/Aggregates/SharePoolingAsset/Entities/SharePoolingAssetAcquisitionTest.php +++ b/domain/tests/Aggregates/SharePoolingAsset/Entities/SharePoolingAssetAcquisitionTest.php @@ -63,6 +63,7 @@ it('can return the various quantities', function () { /** @var SharePoolingAssetAcquisition */ $acquisition = SharePoolingAssetAcquisition::factory()->make([ + 'quantity' => new Quantity('100'), 'sameDayQuantity' => new Quantity('30'), 'thirtyDayQuantity' => new Quantity('60'), ]); @@ -78,7 +79,21 @@ expect((string) $acquisition->availableThirtyDayQuantity())->toBe('10'); }); -it('can increase the same-day quantity', function (string $increase, string $sameDayQuantity, string $thirtyDayQuantity) { +it('cannot increase the same-day quantity because the quantity is too great', function () { + /** @var SharePoolingAssetAcquisition */ + $acquisition = SharePoolingAssetAcquisition::factory()->make([ + 'quantity' => new Quantity('100'), + 'sameDayQuantity' => new Quantity('30'), + 'thirtyDayQuantity' => new Quantity('60'), + ]); + + expect(fn () => $acquisition->increaseSameDayQuantity(new Quantity('11')))->toThrow( + SharePoolingAssetAcquisitionException::class, + SharePoolingAssetAcquisitionException::insufficientSameDayQuantityToIncrease(new Quantity('11'), new Quantity('10'))->getMessage(), + ); +}); + +it('can increase the same-day quantity', function (string $increase, string $sameDayQuantity) { /** @var SharePoolingAssetAcquisition */ $acquisition = SharePoolingAssetAcquisition::factory()->make([ 'quantity' => new Quantity('100'), @@ -88,13 +103,35 @@ $acquisition->increaseSameDayQuantity(new Quantity($increase)); + expect((string) $acquisition->sameDayQuantity())->toBe($sameDayQuantity); +})->with([ + 'scenario 1' => ['5', '35'], + 'scenario 2' => ['10', '40'], +]); + +it('can increase the same-day quantity up to the available quantity', function ( + string $increase, + string $sameDayQuantity, + string $thirtyDayQuantity, + string $addedQuantity, +) { + /** @var SharePoolingAssetAcquisition */ + $acquisition = SharePoolingAssetAcquisition::factory()->make([ + 'quantity' => new Quantity('100'), + 'sameDayQuantity' => new Quantity('30'), + 'thirtyDayQuantity' => new Quantity('60'), + ]); + + $added = $acquisition->increaseSameDayQuantityUpToAvailableQuantity(new Quantity($increase)); + expect((string) $acquisition->sameDayQuantity())->toBe($sameDayQuantity); expect((string) $acquisition->thirtyDayQuantity())->toBe($thirtyDayQuantity); + expect((string) $added)->toBe($addedQuantity); })->with([ - 'scenario 1' => ['5', '35', '55'], - 'scenario 2' => ['10', '40', '50'], - 'scenario 3' => ['70', '100', '0'], - 'scenario 4' => ['71', '100', '0'], + 'scenario 1' => ['5', '35', '55', '5'], + 'scenario 2' => ['10', '40', '50', '10'], + 'scenario 3' => ['70', '100', '0', '70'], + 'scenario 4' => ['71', '100', '0', '70'], ]); it('cannot decrease the same-day quantity because the quantity is too great', function () { @@ -107,7 +144,7 @@ expect(fn () => $acquisition->decreaseSameDayQuantity(new Quantity('31')))->toThrow( SharePoolingAssetAcquisitionException::class, - SharePoolingAssetAcquisitionException::insufficientSameDayQuantity(new Quantity('31'), new Quantity('30'))->getMessage(), + SharePoolingAssetAcquisitionException::insufficientSameDayQuantityToDecrease(new Quantity('31'), new Quantity('30'))->getMessage(), ); }); @@ -124,7 +161,21 @@ expect((string) $acquisition->sameDayQuantity())->toBe('20'); }); -it('can increase the 30-day quantity', function (string $increase, string $sameDayQuantity, string $thirtyDayQuantity) { +it('cannot increase the 30-day quantity because the quantity is too great', function () { + /** @var SharePoolingAssetAcquisition */ + $acquisition = SharePoolingAssetAcquisition::factory()->make([ + 'quantity' => new Quantity('100'), + 'sameDayQuantity' => new Quantity('30'), + 'thirtyDayQuantity' => new Quantity('60'), + ]); + + expect(fn () => $acquisition->increaseThirtyDayQuantity(new Quantity('11')))->toThrow( + SharePoolingAssetAcquisitionException::class, + SharePoolingAssetAcquisitionException::insufficientThirtyDayQuantityToIncrease(new Quantity('11'), new Quantity('10'))->getMessage(), + ); +}); + +it('can increase the 30-day quantity', function (string $increase, string $thirtyDayQuantity) { /** @var SharePoolingAssetAcquisition */ $acquisition = SharePoolingAssetAcquisition::factory()->make([ 'quantity' => new Quantity('100'), @@ -134,12 +185,34 @@ $acquisition->increaseThirtyDayQuantity(new Quantity($increase)); + expect((string) $acquisition->thirtyDayQuantity())->toBe($thirtyDayQuantity); +})->with([ + 'scenario 1' => ['5', '65'], + 'scenario 2' => ['10', '70'], +]); + +it('can increase the 30-day quantity up to the available quantity', function ( + string $increase, + string $sameDayQuantity, + string $thirtyDayQuantity, + string $addedQuantity, +) { + /** @var SharePoolingAssetAcquisition */ + $acquisition = SharePoolingAssetAcquisition::factory()->make([ + 'quantity' => new Quantity('100'), + 'sameDayQuantity' => new Quantity('30'), + 'thirtyDayQuantity' => new Quantity('60'), + ]); + + $added = $acquisition->increaseThirtyDayQuantityUpToAvailableQuantity(new Quantity($increase)); + expect((string) $acquisition->sameDayQuantity())->toBe($sameDayQuantity); expect((string) $acquisition->thirtyDayQuantity())->toBe($thirtyDayQuantity); + expect((string) $added)->toBe($addedQuantity); })->with([ - 'scenario 1' => ['5', '30', '65'], - 'scenario 2' => ['10', '30', '70'], - 'scenario 3' => ['15', '30', '70'], + 'scenario 1' => ['5', '30', '65', '5'], + 'scenario 2' => ['10', '30', '70', '10'], + 'scenario 3' => ['15', '30', '70', '10'], ]); it('cannot decrease the 30-day quantity because the quantity is too great', function () { @@ -152,7 +225,7 @@ expect(fn () => $acquisition->decreaseThirtyDayQuantity(new Quantity('61')))->toThrow( SharePoolingAssetAcquisitionException::class, - SharePoolingAssetAcquisitionException::insufficientThirtyDayQuantity(new Quantity('61'), new Quantity('60'))->getMessage(), + SharePoolingAssetAcquisitionException::insufficientThirtyDayQuantityToDecrease(new Quantity('61'), new Quantity('60'))->getMessage(), ); }); diff --git a/domain/tests/Aggregates/SharePoolingAsset/Entities/SharePoolingAssetDisposalsTest.php b/domain/tests/Aggregates/SharePoolingAsset/Entities/SharePoolingAssetDisposalsTest.php index dfd88c7..04310c9 100644 --- a/domain/tests/Aggregates/SharePoolingAsset/Entities/SharePoolingAssetDisposalsTest.php +++ b/domain/tests/Aggregates/SharePoolingAsset/Entities/SharePoolingAssetDisposalsTest.php @@ -69,22 +69,6 @@ expect($disposals->getIterator()[2])->toBe($disposal1); }); -it('can return the unprocessed disposals from a collection of disposals', function () { - /** @var list */ - $items = [ - SharePoolingAssetDisposal::factory()->make(), - $unprocessed1 = SharePoolingAssetDisposal::factory()->unprocessed()->make(), - SharePoolingAssetDisposal::factory()->make(), - $unprocessed2 = SharePoolingAssetDisposal::factory()->unprocessed()->make(), - ]; - - $disposals = SharePoolingAssetDisposals::make(...$items)->unprocessed(); - - expect($disposals->count())->toBeInt()->toBe(2); - expect($disposals->getIterator()[0])->toBe($unprocessed1); - expect($disposals->getIterator()[1])->toBe($unprocessed2); -}); - it('can return the disposals with available same-day quantity from a collection of disposals', function () { /** @var list */ $items = [ diff --git a/domain/tests/Aggregates/SharePoolingAsset/Entities/SharePoolingAssetTransactionsTest.php b/domain/tests/Aggregates/SharePoolingAsset/Entities/SharePoolingAssetTransactionsTest.php index 8c72081..e6aa3a5 100644 --- a/domain/tests/Aggregates/SharePoolingAsset/Entities/SharePoolingAssetTransactionsTest.php +++ b/domain/tests/Aggregates/SharePoolingAsset/Entities/SharePoolingAssetTransactionsTest.php @@ -39,6 +39,21 @@ expect($transactions->count())->toBeInt()->toBe(3); }); +it('can return a copy of a collection of transactions', function () { + /** @var list */ + $items = [ + $first = SharePoolingAssetAcquisition::factory()->make(), + $second = SharePoolingAssetDisposal::factory()->make(), + ]; + + $transactions = SharePoolingAssetTransactions::make(...$items)->copy(); + + expect($first)->not->toBe($transactions->get(0)); + expect($second)->not->toBe($transactions->get(1)); + expect($first)->toEqual($transactions->get(0)); + expect($second)->toEqual($transactions->get(1)); +}); + it('can return the first transaction of a collection', function () { /** @var list */ $items = [ @@ -502,7 +517,7 @@ SharePoolingAssetDisposal::factory()->withThirtyDayQuantity(new Quantity('20'), id: $acquisition2->id)->make(), $disposal2 = SharePoolingAssetDisposal::factory()->withThirtyDayQuantity(new Quantity('70'), id: $acquisition1->id)->make(), SharePoolingAssetDisposal::factory()->make(), - )->disposalsWithThirtyDayQuantityMatchedWith($acquisition1); + )->disposalsWithThirtyDayQuantityAllocatedTo($acquisition1); expect($transactions)->toBeInstanceOf(SharePoolingAssetDisposals::class); expect($transactions->count())->toEqual(2); diff --git a/domain/tests/Aggregates/SharePoolingAsset/Services/DisposalProcessor/DisposalProcessorTest.php b/domain/tests/Aggregates/SharePoolingAsset/Services/DisposalBuilder/DisposalBuilderTest.php similarity index 87% rename from domain/tests/Aggregates/SharePoolingAsset/Services/DisposalProcessor/DisposalProcessorTest.php rename to domain/tests/Aggregates/SharePoolingAsset/Services/DisposalBuilder/DisposalBuilderTest.php index 483e1c5..b0460bc 100644 --- a/domain/tests/Aggregates/SharePoolingAsset/Services/DisposalProcessor/DisposalProcessorTest.php +++ b/domain/tests/Aggregates/SharePoolingAsset/Services/DisposalBuilder/DisposalBuilderTest.php @@ -4,7 +4,7 @@ use Domain\Aggregates\SharePoolingAsset\Actions\DisposeOfSharePoolingAsset; use Domain\Aggregates\SharePoolingAsset\Entities\SharePoolingAssetAcquisition; use Domain\Aggregates\SharePoolingAsset\Entities\SharePoolingAssetTransactions; -use Domain\Aggregates\SharePoolingAsset\Services\DisposalProcessor\DisposalProcessor; +use Domain\Aggregates\SharePoolingAsset\Services\DisposalBuilder\DisposalBuilder; use Domain\Aggregates\SharePoolingAsset\ValueObjects\SharePoolingAssetTransactionId; use Domain\ValueObjects\Asset; use Domain\ValueObjects\FiatAmount; @@ -20,7 +20,7 @@ forFiat: false, ); - $disposal = DisposalProcessor::process( + $disposal = DisposalBuilder::make( disposal: $action, transactions: SharePoolingAssetTransactions::make(SharePoolingAssetAcquisition::factory()->make()), ); @@ -40,7 +40,7 @@ $acquisition = SharePoolingAssetAcquisition::factory()->make(['date' => LocalDate::parse('2021-10-22')]); - $disposal = DisposalProcessor::process($action, SharePoolingAssetTransactions::make($acquisition)); + $disposal = DisposalBuilder::make($action, SharePoolingAssetTransactions::make($acquisition)); expect($disposal->id)->toBe($id); }); diff --git a/domain/tests/Aggregates/SharePoolingAsset/Services/QuantityAdjuster/QuantityAdjusterTest.php b/domain/tests/Aggregates/SharePoolingAsset/Services/QuantityAdjuster/QuantityAdjusterTest.php index 0a9cccb..b509ac1 100644 --- a/domain/tests/Aggregates/SharePoolingAsset/Services/QuantityAdjuster/QuantityAdjusterTest.php +++ b/domain/tests/Aggregates/SharePoolingAsset/Services/QuantityAdjuster/QuantityAdjusterTest.php @@ -1,5 +1,6 @@ withSameDayQuantity(Quantity::zero(), $id = SharePoolingAssetTransactionId::fromString('foo')) ->make(); - expect(fn () => QuantityAdjuster::revertDisposal($disposal, SharePoolingAssetTransactions::make())) + expect(fn () => QuantityAdjuster::$method($disposal, SharePoolingAssetTransactions::make())) ->toThrow(QuantityAdjusterException::class, QuantityAdjusterException::transactionNotFound($id)->getMessage()); -}); +})->with(['applyDisposal', 'revertDisposal']); -it('cannot revert a disposal because a transaction is not an acquisition', function () { +it('cannot process a disposal because a transaction is not an acquisition', function (string $method) { $disposal = SharePoolingAssetDisposal::factory() ->withSameDayQuantity(Quantity::zero(), $id = SharePoolingAssetTransactionId::fromString('foo')) ->make(); $transactions = SharePoolingAssetTransactions::make(SharePoolingAssetDisposal::factory()->make(['id' => $id])); - expect(fn () => QuantityAdjuster::revertDisposal($disposal, $transactions)) + expect(fn () => QuantityAdjuster::$method($disposal, $transactions)) ->toThrow(QuantityAdjusterException::class, QuantityAdjusterException::notAnAcquisition($id)->getMessage()); -}); +})->with(['applyDisposal', 'revertDisposal']); -it('reverts a disposal despite insufficient same-day quantity', function () { +it('cannot apply a disposal because of insufficient available quantity to increase', function ( + string $method, + string $quantity, + string $exception +) { $disposal = SharePoolingAssetDisposal::factory() - ->withSameDayQuantity(new Quantity('100'), $id = SharePoolingAssetTransactionId::fromString('foo')) + ->{$method}(new Quantity($quantity), $id = SharePoolingAssetTransactionId::fromString('foo')) + ->make(); + + $transactions = SharePoolingAssetTransactions::make(SharePoolingAssetAcquisition::factory()->make([ + 'id' => $id, + 'quantity' => $available = new Quantity('10'), + ])); + + expect(fn () => QuantityAdjuster::applyDisposal($disposal, $transactions))->toThrow( + SharePoolingAssetAcquisitionException::class, + SharePoolingAssetAcquisitionException::$exception(new Quantity($quantity), $available)->getMessage(), + ); +})->with([ + 'same-day' => ['withSameDayQuantity', '11', 'insufficientSameDayQuantityToIncrease'], + '30-day' => ['withThirtyDayQuantity', '11', 'insufficientThirtyDayQuantityToIncrease'], +]); + +it('can apply a disposal', function (string $factoryMethod, string $method) { + $disposal = SharePoolingAssetDisposal::factory() + ->{$factoryMethod}(new Quantity('10'), $id = SharePoolingAssetTransactionId::fromString('foo')) + ->make(); + + $transactions = SharePoolingAssetTransactions::make(SharePoolingAssetAcquisition::factory()->make([ + 'id' => $id, + 'quantity' => new Quantity('10'), + ])); + + expect((string) $transactions->first()->{$method}())->toBe('0'); + + QuantityAdjuster::applyDisposal($disposal, $transactions); + + expect((string) $transactions->first()->{$method}())->toBe('10'); +})->with([ + 'same-day' => ['withSameDayQuantity', 'sameDayQuantity'], + '30-day' => ['withThirtyDayQuantity', 'thirtyDayQuantity'], +]); + +it('cannot revert a disposal because of insufficient available quantity to decrease', function ( + string $method, + string $quantity, + string $exception +) { + $disposal = SharePoolingAssetDisposal::factory() + ->{$method}(new Quantity($quantity), $id = SharePoolingAssetTransactionId::fromString('foo')) ->make(); $transactions = SharePoolingAssetTransactions::make(SharePoolingAssetAcquisition::factory()->make(['id' => $id])); - expect(QuantityAdjuster::revertDisposal($disposal, $transactions))->not->toThrow(QuantityAdjusterException::class); -}); + expect(fn () => QuantityAdjuster::revertDisposal($disposal, $transactions))->toThrow( + SharePoolingAssetAcquisitionException::class, + SharePoolingAssetAcquisitionException::$exception(new Quantity($quantity), Quantity::zero())->getMessage(), + ); +})->with([ + 'same-day' => ['withSameDayQuantity', '1', 'insufficientSameDayQuantityToDecrease'], + '30-day' => ['withThirtyDayQuantity', '1', 'insufficientThirtyDayQuantityToDecrease'], +]); + +it('can revert a disposal', function (string $factoryMethod, string $method) { + $disposal = SharePoolingAssetDisposal::factory() + ->{$factoryMethod}(new Quantity('10'), $id = SharePoolingAssetTransactionId::fromString('foo')) + ->make(); + + $transactions = SharePoolingAssetTransactions::make(SharePoolingAssetAcquisition::factory()->make([ + 'id' => $id, + 'sameDayQuantity' => new Quantity('10'), + 'thirtyDayQuantity' => new Quantity('10'), + ])); + + expect((string) $transactions->first()->{$method}())->toBe('10'); + + QuantityAdjuster::revertDisposal($disposal, $transactions); + + expect((string) $transactions->first()->{$method}())->toBe('0'); +})->with([ + 'same-day' => ['withSameDayQuantity', 'sameDayQuantity'], + '30-day' => ['withThirtyDayQuantity', 'thirtyDayQuantity'], +]); diff --git a/domain/tests/Aggregates/SharePoolingAsset/SharePoolingAssetTest.php b/domain/tests/Aggregates/SharePoolingAsset/SharePoolingAssetTest.php index 771455d..e8494ed 100644 --- a/domain/tests/Aggregates/SharePoolingAsset/SharePoolingAssetTest.php +++ b/domain/tests/Aggregates/SharePoolingAsset/SharePoolingAssetTest.php @@ -287,7 +287,7 @@ )); }); -it('can dispose of a share pooling asset on the same day they were acquired', function () { +it('can dispose of a share pooling asset on the same day it was acquired', function () { given(new SharePoolingAssetFiatCurrencySet(FiatCurrency::GBP)); given(new SharePoolingAssetAcquired( @@ -392,7 +392,7 @@ )); }); -it('can acquire a share pooling asset within 30 days of their disposal', function () { +it('can acquire a share pooling asset within 30 days of its disposal', function () { given(new SharePoolingAssetFiatCurrencySet(FiatCurrency::GBP)); given(new SharePoolingAssetAcquired( @@ -432,7 +432,6 @@ quantity: $acquireMoreSharePoolingAsset->quantity, costBasis: $acquireMoreSharePoolingAsset->costBasis, forFiat: false, - thirtyDayQuantity: new Quantity('25'), ), ), new SharePoolingAssetDisposedOf( @@ -447,7 +446,7 @@ ); }); -it('can acquire a share pooling asset several times within 30 days of their disposal', function () { +it('can acquire a share pooling asset several times within 30 days of its disposal', function () { given(new SharePoolingAssetFiatCurrencySet(FiatCurrency::GBP)); given(new SharePoolingAssetAcquired( @@ -465,7 +464,6 @@ quantity: new Quantity('25'), costBasis: FiatAmount::GBP('50'), forFiat: false, - sameDayQuantity: new Quantity('25'), ), )); @@ -489,18 +487,17 @@ ), )); + given(new SharePoolingAssetDisposalReverted(disposal: $sharePoolingAssetDisposedOf1->disposal)); + given($sharePoolingAssetAcquired3 = new SharePoolingAssetAcquired( acquisition: new SharePoolingAssetAcquisition( date: LocalDate::parse('2015-10-28'), quantity: new Quantity('20'), costBasis: FiatAmount::GBP('60'), forFiat: false, - thirtyDayQuantity: new Quantity('20'), ), )); - given(new SharePoolingAssetDisposalReverted(disposal: $sharePoolingAssetDisposedOf1->disposal)); - given($sharePoolingAssetDisposedOf1Corrected1 = new SharePoolingAssetDisposedOf( disposal: SharePoolingAssetDisposal::factory() ->copyFrom($sharePoolingAssetDisposedOf1->disposal) @@ -527,7 +524,6 @@ quantity: $acquireSharePoolingAsset4->quantity, costBasis: $acquireSharePoolingAsset4->costBasis, forFiat: false, - thirtyDayQuantity: new Quantity('20'), ), ), new SharePoolingAssetDisposedOf( @@ -563,7 +559,6 @@ quantity: new Quantity('25'), costBasis: FiatAmount::GBP('50'), forFiat: false, - sameDayQuantity: new Quantity('25'), ), )); @@ -586,18 +581,17 @@ ]), )); + given(new SharePoolingAssetDisposalReverted(disposal: $sharePoolingAssetDisposedOf1->disposal)); + given($sharePoolingAssetAcquired3 = new SharePoolingAssetAcquired( acquisition: new SharePoolingAssetAcquisition( date: LocalDate::parse('2015-10-28'), quantity: new Quantity('20'), costBasis: FiatAmount::GBP('60'), forFiat: false, - thirtyDayQuantity: new Quantity('20'), ), )); - given(new SharePoolingAssetDisposalReverted(disposal: $sharePoolingAssetDisposedOf1->disposal)); - given($sharePoolingAssetDisposedOf1Corrected1 = new SharePoolingAssetDisposedOf( disposal: SharePoolingAssetDisposal::factory() ->copyFrom($sharePoolingAssetDisposedOf1->disposal) @@ -605,22 +599,19 @@ ->make(['costBasis' => FiatAmount::GBP('115')]), )); + given(new SharePoolingAssetDisposalReverted(disposal: $sharePoolingAssetDisposedOf1Corrected1->disposal)); + + given(new SharePoolingAssetDisposalReverted(disposal: $sharePoolingAssetDisposedOf2->disposal)); + given($sharePoolingAssetAcquired4 = new SharePoolingAssetAcquired( acquisition: new SharePoolingAssetAcquisition( date: LocalDate::parse('2015-10-29'), quantity: new Quantity('20'), costBasis: FiatAmount::GBP('40'), forFiat: false, - thirtyDayQuantity: new Quantity('20'), ), )); - given(new SharePoolingAssetDisposalReverted(disposal: $sharePoolingAssetDisposedOf1Corrected1->disposal)); - - given(new SharePoolingAssetDisposalReverted(disposal: $sharePoolingAssetDisposedOf2->disposal)); - - given(new SharePoolingAssetDisposedOf(disposal: $sharePoolingAssetDisposedOf1Corrected1->disposal)); - given(new SharePoolingAssetDisposedOf( disposal: SharePoolingAssetDisposal::factory() ->copyFrom($sharePoolingAssetDisposedOf1Corrected1->disposal) @@ -632,7 +623,7 @@ disposal: SharePoolingAssetDisposal::factory() ->copyFrom($sharePoolingAssetDisposedOf2->disposal) ->withThirtyDayQuantity(new Quantity('15'), id: $sharePoolingAssetAcquired4->acquisition->id) - ->make(['costBasis' => FiatAmount::GBP('40')]), + ->make(['costBasis' => FiatAmount::GBP('35')]), )); when($disposeOfSharePoolingAsset3 = new DisposeOfSharePoolingAsset( @@ -646,12 +637,6 @@ then( new SharePoolingAssetDisposalReverted(disposal: $sharePoolingAssetDisposedOf2Corrected1->disposal), - new SharePoolingAssetDisposedOf( - disposal: SharePoolingAssetDisposal::factory() - ->revert($sharePoolingAssetDisposedOf2->disposal) - ->withThirtyDayQuantity(new Quantity('5'), id: $sharePoolingAssetAcquired4->acquisition->id) - ->make(['costBasis' => FiatAmount::GBP('30')]), - ), new SharePoolingAssetDisposedOf( disposal: SharePoolingAssetDisposal::factory() ->withSameDayQuantity(new Quantity('10'), id: $sharePoolingAssetAcquired4->acquisition->id) @@ -661,13 +646,79 @@ 'quantity' => $disposeOfSharePoolingAsset3->quantity, 'costBasis' => FiatAmount::GBP('20'), 'proceeds' => $disposeOfSharePoolingAsset3->proceeds, - 'thirtyDayQuantityAllocation' => new QuantityAllocation(), ]), ), + new SharePoolingAssetDisposedOf( + disposal: SharePoolingAssetDisposal::factory() + ->revert($sharePoolingAssetDisposedOf2->disposal) + ->withThirtyDayQuantity(new Quantity('5'), id: $sharePoolingAssetAcquired4->acquisition->id) + ->make(['costBasis' => FiatAmount::GBP('30')]), + ), + ); +}); + +it('can acquire a share pooling asset within 30 days of a disposal that was on the same day as an acquisition', function () { + given(new SharePoolingAssetFiatCurrencySet(FiatCurrency::GBP)); + + given(new SharePoolingAssetAcquired( + acquisition: new SharePoolingAssetAcquisition( + date: LocalDate::parse('2015-10-21'), + quantity: new Quantity('30'), + costBasis: FiatAmount::GBP('60'), + forFiat: false, + ), + )); + + given($sharePoolingAssetAcquired = new SharePoolingAssetAcquired( + acquisition: new SharePoolingAssetAcquisition( + date: LocalDate::parse('2015-11-25'), + quantity: new Quantity('40'), + costBasis: FiatAmount::GBP('40'), + forFiat: false, + ), + )); + + given($sharePoolingAssetDisposedOf = new SharePoolingAssetDisposedOf( + disposal: SharePoolingAssetDisposal::factory() + ->withSameDayQuantity(new Quantity('40'), id: $sharePoolingAssetAcquired->acquisition->id) + ->make([ + 'date' => LocalDate::parse('2015-11-25'), + 'quantity' => new Quantity('50'), + 'costBasis' => FiatAmount::GBP('60'), + ]), + )); + + when($acquireSharePoolingAsset = new AcquireSharePoolingAsset( + transactionId: SharePoolingAssetTransactionId::generate(), + asset: $this->aggregateRootId->toAsset(), + date: LocalDate::parse('2015-11-28'), + quantity: new Quantity('40'), + costBasis: FiatAmount::GBP('20'), + forFiat: false, + )); + + then( + new SharePoolingAssetDisposalReverted(disposal: $sharePoolingAssetDisposedOf->disposal), + new SharePoolingAssetAcquired( + acquisition: new SharePoolingAssetAcquisition( + id: $acquireSharePoolingAsset->transactionId, + date: $acquireSharePoolingAsset->date, + quantity: $acquireSharePoolingAsset->quantity, + costBasis: $acquireSharePoolingAsset->costBasis, + forFiat: false, + ), + ), + new SharePoolingAssetDisposedOf( + disposal: SharePoolingAssetDisposal::factory() + ->revert($sharePoolingAssetDisposedOf->disposal) + ->withSameDayQuantity(new Quantity('40'), id: $sharePoolingAssetAcquired->acquisition->id) + ->withThirtyDayQuantity(new Quantity('10'), id: $acquireSharePoolingAsset->transactionId) + ->make(['costBasis' => FiatAmount::GBP('45')]), + ), ); }); -it('can acquire a same-day share pooling asset several times on the same day as their disposal', function () { +it('can acquire a share pooling asset several times on the same day as its disposal', function () { given(new SharePoolingAssetFiatCurrencySet(FiatCurrency::GBP)); given(new SharePoolingAssetAcquired( @@ -697,7 +748,6 @@ quantity: new Quantity('20'), costBasis: FiatAmount::GBP('25'), forFiat: false, - sameDayQuantity: new Quantity('20'), // $sharePoolingAssetDisposedOf ), )); @@ -730,7 +780,6 @@ quantity: $acquireSharePoolingAsset3->quantity, costBasis: $acquireSharePoolingAsset3->costBasis, forFiat: false, - sameDayQuantity: new Quantity('10'), // $sharePoolingAssetDisposedOf ), ), new SharePoolingAssetDisposedOf( @@ -743,7 +792,7 @@ ); }); -it('can dispose of a same-day share pooling asset several times on the same day as several acquisitions', function () { +it('can dispose of a share pooling asset several times on the same day as several acquisitions', function () { given(new SharePoolingAssetFiatCurrencySet(FiatCurrency::GBP)); given(new SharePoolingAssetAcquired( @@ -773,7 +822,6 @@ quantity: new Quantity('20'), costBasis: FiatAmount::GBP('25'), forFiat: false, - sameDayQuantity: new Quantity('20'), // $sharePoolingAssetDisposedOf1 ), )); @@ -792,7 +840,6 @@ quantity: new Quantity('60'), costBasis: FiatAmount::GBP('90'), forFiat: false, - sameDayQuantity: new Quantity('30'), // $sharePoolingAssetDisposedOf1 ), )); @@ -824,3 +871,218 @@ ]), )); }); + +it('can dispose of a share pooling asset several times on the same day and within 30 days of a disposal', function () { + given(new SharePoolingAssetFiatCurrencySet(FiatCurrency::GBP)); + + given(new SharePoolingAssetAcquired( + acquisition: new SharePoolingAssetAcquisition( + date: LocalDate::parse('2015-10-21'), + quantity: new Quantity('100'), + costBasis: FiatAmount::GBP('100'), + forFiat: false, + ), + )); + + given($sharePoolingAssetDisposedOf1 = new SharePoolingAssetDisposedOf( + disposal: new SharePoolingAssetDisposal( + date: LocalDate::parse('2015-10-22'), + quantity: new Quantity('30'), + costBasis: FiatAmount::GBP('30'), + proceeds: FiatAmount::GBP('50'), + forFiat: false, + ), + )); + + given(new SharePoolingAssetDisposalReverted(disposal: $sharePoolingAssetDisposedOf1->disposal)); + + given($sharePoolingAssetAcquired2 = new SharePoolingAssetAcquired( + acquisition: new SharePoolingAssetAcquisition( + date: LocalDate::parse('2015-10-23'), + quantity: new Quantity('20'), + costBasis: FiatAmount::GBP('40'), + forFiat: false, + ), + )); + + given($sharePoolingAssetDisposedOf1Corrected1 = new SharePoolingAssetDisposedOf( + disposal: SharePoolingAssetDisposal::factory() + ->copyFrom($sharePoolingAssetDisposedOf1->disposal) + ->withThirtyDayQuantity(new Quantity('20'), id: $sharePoolingAssetAcquired2->acquisition->id) + ->make(['costBasis' => FiatAmount::GBP('50')]), + )); + + given(new SharePoolingAssetDisposalReverted(disposal: $sharePoolingAssetDisposedOf1Corrected1->disposal)); + + given(new SharePoolingAssetDisposedOf( + disposal: SharePoolingAssetDisposal::factory() + ->withSameDayQuantity(new Quantity('10'), id: $sharePoolingAssetAcquired2->acquisition->id) + ->make([ + 'date' => LocalDate::parse('2015-10-23'), + 'quantity' => new Quantity('10'), + 'costBasis' => FiatAmount::GBP('20'), + 'proceeds' => FiatAmount::GBP('30'), + 'forFiat' => false, + ]), + )); + + given($sharePoolingAssetDisposedOf1Corrected2 = new SharePoolingAssetDisposedOf( + disposal: SharePoolingAssetDisposal::factory() + ->copyFrom($sharePoolingAssetDisposedOf1->disposal) + ->withThirtyDayQuantity(new Quantity('10'), id: $sharePoolingAssetAcquired2->acquisition->id) + ->make(['costBasis' => FiatAmount::GBP('40')]), + )); + + when($disposeOfSharePoolingAsset3 = new DisposeOfSharePoolingAsset( + transactionId: SharePoolingAssetTransactionId::generate(), + asset: $this->aggregateRootId->toAsset(), + date: LocalDate::parse('2015-10-23'), + quantity: new Quantity('15'), + proceeds: FiatAmount::GBP('40'), + forFiat: false, + )); + + then( + new SharePoolingAssetDisposalReverted(disposal: $sharePoolingAssetDisposedOf1Corrected2->disposal), + new SharePoolingAssetDisposedOf( + disposal: SharePoolingAssetDisposal::factory() + ->withSameDayQuantity(new Quantity('10'), id: $sharePoolingAssetAcquired2->acquisition->id) + ->make([ + 'id' => $disposeOfSharePoolingAsset3->transactionId, + 'date' => $disposeOfSharePoolingAsset3->date, + 'quantity' => $disposeOfSharePoolingAsset3->quantity, + 'costBasis' => FiatAmount::GBP('25'), + 'proceeds' => $disposeOfSharePoolingAsset3->proceeds, + ]), + ), + new SharePoolingAssetDisposedOf( + disposal: SharePoolingAssetDisposal::factory() + ->copyFrom($sharePoolingAssetDisposedOf1->disposal) + ->make(['costBasis' => FiatAmount::GBP('30')]), + ), + ); +}); + +it('can acquire a share pooling asset on the same day as other acquisitions and disposals and within 30 days of a disposal', function () { + given(new SharePoolingAssetFiatCurrencySet(FiatCurrency::GBP)); + + given(new SharePoolingAssetAcquired( + acquisition: new SharePoolingAssetAcquisition( + date: LocalDate::parse('2015-10-21'), + quantity: new Quantity('100'), + costBasis: FiatAmount::GBP('100'), + forFiat: false, + ), + )); + + given($sharePoolingAssetDisposedOf1 = new SharePoolingAssetDisposedOf( + disposal: new SharePoolingAssetDisposal( + date: LocalDate::parse('2015-10-22'), + quantity: new Quantity('30'), + costBasis: FiatAmount::GBP('30'), + proceeds: FiatAmount::GBP('50'), + forFiat: false, + ), + )); + + given(new SharePoolingAssetDisposalReverted(disposal: $sharePoolingAssetDisposedOf1->disposal)); + + given($sharePoolingAssetAcquired2 = new SharePoolingAssetAcquired( + acquisition: new SharePoolingAssetAcquisition( + date: LocalDate::parse('2015-10-23'), + quantity: new Quantity('20'), + costBasis: FiatAmount::GBP('40'), + forFiat: false, + ), + )); + + given($sharePoolingAssetDisposedOf1Corrected1 = new SharePoolingAssetDisposedOf( + disposal: SharePoolingAssetDisposal::factory() + ->copyFrom($sharePoolingAssetDisposedOf1->disposal) + ->withThirtyDayQuantity(new Quantity('20'), id: $sharePoolingAssetAcquired2->acquisition->id) + ->make(['costBasis' => FiatAmount::GBP('50')]), + )); + + given(new SharePoolingAssetDisposalReverted(disposal: $sharePoolingAssetDisposedOf1Corrected1->disposal)); + + given($sharePoolingAssetDisposedOf2 = new SharePoolingAssetDisposedOf( + disposal: SharePoolingAssetDisposal::factory() + ->withSameDayQuantity(new Quantity('10'), id: $sharePoolingAssetAcquired2->acquisition->id) + ->make([ + 'date' => LocalDate::parse('2015-10-23'), + 'quantity' => new Quantity('10'), + 'costBasis' => FiatAmount::GBP('20'), + 'proceeds' => FiatAmount::GBP('30'), + 'forFiat' => false, + ]), + )); + + given($sharePoolingAssetDisposedOf1Corrected2 = new SharePoolingAssetDisposedOf( + disposal: SharePoolingAssetDisposal::factory() + ->copyFrom($sharePoolingAssetDisposedOf1->disposal) + ->withThirtyDayQuantity(new Quantity('10'), id: $sharePoolingAssetAcquired2->acquisition->id) + ->make(['costBasis' => FiatAmount::GBP('40')]), + )); + + given(new SharePoolingAssetDisposalReverted(disposal: $sharePoolingAssetDisposedOf1Corrected2->disposal)); + + given($sharePoolingAssetDisposedOf3 = new SharePoolingAssetDisposedOf( + disposal: SharePoolingAssetDisposal::factory() + ->withSameDayQuantity(new Quantity('10'), id: $sharePoolingAssetAcquired2->acquisition->id) + ->make([ + 'date' => LocalDate::parse('2015-10-23'), + 'quantity' => new Quantity('15'), + 'costBasis' => FiatAmount::GBP('25'), + 'proceeds' => FiatAmount::GBP('40'), + ]), + )); + + given($sharePoolingAssetDisposedOf1Corrected3 = new SharePoolingAssetDisposedOf( + disposal: SharePoolingAssetDisposal::factory() + ->copyFrom($sharePoolingAssetDisposedOf1->disposal) + ->make(['costBasis' => FiatAmount::GBP('30')]), + )); + + when($acquireSharePoolingAsset3 = new AcquireSharePoolingAsset( + transactionId: SharePoolingAssetTransactionId::generate(), + asset: $this->aggregateRootId->toAsset(), + date: LocalDate::parse('2015-10-23'), + quantity: new Quantity('10'), + costBasis: FiatAmount::GBP('50'), + forFiat: false, + )); + + then( + new SharePoolingAssetDisposalReverted(disposal: $sharePoolingAssetDisposedOf2->disposal), + new SharePoolingAssetDisposalReverted(disposal: $sharePoolingAssetDisposedOf3->disposal), + new SharePoolingAssetDisposalReverted(disposal: $sharePoolingAssetDisposedOf1Corrected3->disposal), + new SharePoolingAssetAcquired( + acquisition: new SharePoolingAssetAcquisition( + id: $acquireSharePoolingAsset3->transactionId, + date: $acquireSharePoolingAsset3->date, + quantity: $acquireSharePoolingAsset3->quantity, + costBasis: $acquireSharePoolingAsset3->costBasis, + forFiat: false, + ), + ), + new SharePoolingAssetDisposedOf( + disposal: SharePoolingAssetDisposal::factory() + ->revert($sharePoolingAssetDisposedOf2->disposal) + ->withSameDayQuantity(new Quantity('10'), id: $sharePoolingAssetAcquired2->acquisition->id) + ->make(['costBasis' => FiatAmount::GBP('30')]), + ), + new SharePoolingAssetDisposedOf( + disposal: SharePoolingAssetDisposal::factory() + ->revert($sharePoolingAssetDisposedOf3->disposal) + ->withSameDayQuantity(new Quantity('10'), id: $sharePoolingAssetAcquired2->acquisition->id) + ->withSameDayQuantity(new Quantity('5'), id: $acquireSharePoolingAsset3->transactionId) + ->make(['costBasis' => FiatAmount::GBP('45')]), + ), + new SharePoolingAssetDisposedOf( + disposal: SharePoolingAssetDisposal::factory() + ->copyFrom($sharePoolingAssetDisposedOf1->disposal) + ->withThirtyDayQuantity(new Quantity('5'), id: $acquireSharePoolingAsset3->transactionId) + ->make(['costBasis' => FiatAmount::GBP('50')]), + ), + ); +}); diff --git a/tests/Feature/Commands/ProcessTest.php b/tests/Feature/Commands/ProcessTest.php index 8721335..23e1861 100644 --- a/tests/Feature/Commands/ProcessTest.php +++ b/tests/Feature/Commands/ProcessTest.php @@ -28,9 +28,9 @@ 'tax_year_id' => '2020-2021', 'currency' => FiatCurrency::GBP->value, 'capital_gain' => json_encode([ - 'cost_basis' => '15709.51947887970615243342516069788787', + 'cost_basis' => '16656.83425160697887970615243342516063', 'proceeds' => '27979.5', - 'difference' => '12269.98052112029384756657483930211213', + 'difference' => '11322.66574839302112029384756657483937', ]), 'income' => '1000', 'non_attributable_allowable_cost' => '117',