*/ final readonly class Product implements Entity { public const int ROUNDING = Money::ROUND_UP; private const int GRAMS_PER_KG = 1000; public function __construct( private ProductId $id, public Money $price, public TaxRate $tax_rate, public ?ProductWeight $product_weight, public InvoicingUnit $invoicing_unit, public SellUnit $sell_unit, public ?DiscountValue $validDiscount, public bool $status, public ?CarbonImmutable $orderable_start, public ?CarbonImmutable $orderable_end, public bool $hasInco ) { } #[\Override] public function id(): ProductId { return $this->id; } public function canBeOrderOn(?CarbonInterface $date = null): bool { $target = $date ?? CarbonImmutable::now(); if ($this->orderable_start && $target < $this->orderable_start) { return false; } if ($this->orderable_end && $target > $this->orderable_end) { return false; } return $this->status && $this->hasInco; } public function isOrderable(?CarbonInterface $date = null): bool { return $this->canBeOrderOn($date); } public function isPublished(): bool { return $this->status; } public function hasInco(): bool { return $this->hasInco; } public function hasDiscount(): bool { return $this->validDiscount !== null; } public function basePriceHt(): Money { if ($this->invoicing_unit === InvoicingUnit::KGM && $this->sell_unit === SellUnit::PCE && $this->product_weight) { // * Price per kg => price of the piece: price x grams / 1000 (single rounding, no float) return $this->price ->multiply($this->product_weight->toGrams()) ->divide(self::GRAMS_PER_KG, self::ROUNDING); } return $this->price; } public function basePriceTtc(): Money { return $this->tax_rate->applyTo($this->basePriceHt()); } public function discountAmountHt(): Money { $basePriceHt = $this->basePriceHt(); $discountAmount = $this->validDiscount?->calculateDiscountAmount($basePriceHt) ?? new Money(0, $basePriceHt->getCurrency()); if ($discountAmount->greaterThan($basePriceHt)) { return $basePriceHt; } return $discountAmount; } public function discountedPriceHt(): Money { return $this->basePriceHt()->subtract($this->discountAmountHt()); } public function discountedPriceTtc(): Money { return $this->tax_rate->applyTo($this->discountedPriceHt()); } public function priceKgTtc(): Money { if ($this->invoicing_unit === InvoicingUnit::KGM) { // * The product price is already a price per kg: it is the reference for the kg price $discountAmountKgHt = $this->validDiscount?->calculateDiscountAmount($this->price) ?? new Money(0, $this->price->getCurrency()); $grams = $this->product_weight?->toGrams() ?? 0; if ( $this->validDiscount !== null && ! $this->validDiscount->isPercentage() && $this->sell_unit === SellUnit::PCE && $grams > 0 ) { // * A fixed amount applies to the piece: bring it back to a per-kg amount $discountAmountKgHt = $discountAmountKgHt ->multiply(self::GRAMS_PER_KG) ->divide($grams, self::ROUNDING); } $discountedKgPriceHt = $this->price->subtract($discountAmountKgHt); if ($discountedKgPriceHt->isNegative()) { $discountedKgPriceHt = new Money(0, $this->price->getCurrency()); } return $this->tax_rate->applyTo($discountedKgPriceHt); } $priceTtc = $this->discountedPriceTtc(); $grams = $this->product_weight?->toGrams() ?? 0; // NOTE: If the product is not published or not sold by piece, we don't need to convert the price to per kg if ($grams > 0 && $this->status && $this->invoicing_unit === InvoicingUnit::PCE) { // * Price of the piece => price per kg: price x 1000 / grams (single rounding, no float) return $priceTtc ->multiply(self::GRAMS_PER_KG) ->divide($grams, self::ROUNDING); } return $priceTtc; } public function discountRate(): DiscountRate { if ($this->validDiscount?->isPercentage()) { return DiscountRate::fromPercentage($this->validDiscount->getPercentage()); } return DiscountRate::fromAmounts($this->discountAmountHt(), $this->basePriceHt()); } public function pricing(): ProductPricing { return new ProductPricing( basePriceHt: $this->basePriceHt(), basePriceTtc: $this->basePriceTtc(), discountAmountHt: $this->discountAmountHt(), discountedPriceHt: $this->discountedPriceHt(), discountedPriceTtc: $this->discountedPriceTtc(), priceKgTtc: $this->priceKgTtc(), discountRate: $this->discountRate(), hasDiscount: $this->hasDiscount() ); } }