*/ private const UNIT_BY_CATEGORY = [ ProjectCostLine::CATEGORY_FILAMENT => 'g', ProjectCostLine::CATEGORY_PACKAGING => 'unit', ProjectCostLine::CATEGORY_SHIPPING => 'parcel', ]; /** @var list */ public const ALLOWED_CURRENCIES = [ 'GBP', 'USD', 'EUR', 'CAD', 'AUD', 'NZD', 'JPY', 'CHF', 'SEK', 'NOK', 'DKK', 'PLN', 'CZK', 'SGD', 'HKD', 'MXN', 'ZAR', 'ILS', ]; public static function isValidCurrency(string $currency): bool { return in_array(strtoupper($currency), self::ALLOWED_CURRENCIES, true); } /** * Each category has exactly one accepted unit so a mistyped or mislabelled * unit (e.g. "G", "grams", "kg") is rejected at validation time instead of * silently skipping or mis-applying the gram-to-kilogram conversion. */ public static function isValidUnitForCategory(string $category, string $unit): bool { return isset(self::UNIT_BY_CATEGORY[$category]) && self::UNIT_BY_CATEGORY[$category] === $unit; } /** * @param list $currencies */ public static function assertSameCurrency(array $currencies): void { $unique = array_unique(array_map('strtoupper', $currencies)); if (count($unique) > 1) { throw new InvalidArgumentException('Cannot combine cost figures in different currencies without an explicit conversion: '.implode(', ', $unique)); } } public static function lineTotal(string $category, string $quantity, string $quantityUnit, string $unitRate, string $waste = '0'): string { if (! in_array($category, ProjectCostLine::CATEGORIES, true)) { throw new InvalidArgumentException("Unknown cost category: {$category}"); } if (! self::isValidUnitForCategory($category, $quantityUnit)) { throw new InvalidArgumentException("Invalid quantity unit '{$quantityUnit}' for category '{$category}'; expected '".self::UNIT_BY_CATEGORY[$category]."'."); } $quantityScaled = FixedPoint::toScaled($quantity, self::QUANTITY_SCALE, self::MAX_QUANTITY, allowNegative: false); $wasteScaled = FixedPoint::toScaled($waste, self::QUANTITY_SCALE, self::MAX_QUANTITY, allowNegative: false); $rateScaled = FixedPoint::toScaled($unitRate, self::RATE_SCALE, self::MAX_RATE, allowNegative: false); $effectiveQuantity = FixedPoint::add($quantityScaled, $wasteScaled); $product = FixedPoint::multiply($effectiveQuantity, $rateScaled); $productScale = self::QUANTITY_SCALE + self::RATE_SCALE; if ($category === ProjectCostLine::CATEGORY_FILAMENT && $quantityUnit === 'g') { // Rate is per kilogram, so the result needs an extra /1000. Treating the // product as if it carried 3 more scale places folds that division into // the normal rescale-down-to-amount-scale step. $amount = FixedPoint::rescale($product, $productScale + 3, self::AMOUNT_SCALE); self::assertWithinStorableAmount($amount); return FixedPoint::format($amount, self::AMOUNT_SCALE); } $amount = FixedPoint::rescale($product, $productScale, self::AMOUNT_SCALE); self::assertWithinStorableAmount($amount); return FixedPoint::format($amount, self::AMOUNT_SCALE); } /** * Shipping's units-per-parcel allocation is a distinct concept from the * batch's manufacturing yield (see perUnit()): it divides a parcel's cost by * how many saleable units that one parcel carries, never by how many units * the print batch itself produced. The two divisions must never be chained. */ public static function shippingUnitContribution(string $unitRate, int $unitsPerParcel): string { if ($unitsPerParcel < 1) { throw new InvalidArgumentException('Units per parcel must be a positive number of saleable units.'); } $rateScaled = FixedPoint::toScaled($unitRate, self::RATE_SCALE, self::MAX_RATE, allowNegative: false); $amount = FixedPoint::rescale($rateScaled, self::RATE_SCALE, self::AMOUNT_SCALE); self::assertWithinStorableAmount($amount); return FixedPoint::format(FixedPoint::divideByInt($amount, $unitsPerParcel), self::AMOUNT_SCALE); } private static function assertWithinStorableAmount(int $amountScaled): void { if (abs($amountScaled) > self::MAX_AMOUNT_SCALED) { throw new InvalidArgumentException('Cost amount exceeds the storable precision bound.'); } } /** * @param list $lines * @return array{filament_total: ?string, packaging_total: ?string, shipping_total: ?string, grand_total: ?string} */ public static function aggregate(array $lines): array { $totals = []; foreach (ProjectCostLine::CATEGORIES as $category) { $categoryLines = array_values(array_filter($lines, fn (array $line): bool => $line['category'] === $category)); if ($categoryLines === []) { $totals[$category] = null; continue; } $sum = 0; foreach ($categoryLines as $line) { $sum = FixedPoint::add($sum, FixedPoint::toScaled((string) $line['line_total'], self::AMOUNT_SCALE)); } self::assertWithinStorableAmount($sum); $totals[$category] = FixedPoint::format($sum, self::AMOUNT_SCALE); } $grand = null; foreach ($totals as $value) { if ($value === null) { continue; } $scaled = FixedPoint::toScaled($value, self::AMOUNT_SCALE); $grand = $grand === null ? $scaled : FixedPoint::add($grand, $scaled); } if ($grand !== null) { self::assertWithinStorableAmount($grand); } return [ 'filament_total' => $totals[ProjectCostLine::CATEGORY_FILAMENT], 'packaging_total' => $totals[ProjectCostLine::CATEGORY_PACKAGING], 'shipping_total' => $totals[ProjectCostLine::CATEGORY_SHIPPING], 'grand_total' => $grand === null ? null : FixedPoint::format($grand, self::AMOUNT_SCALE), ]; } public static function perUnit(string $grandTotal, int $batchUnits): string { if ($batchUnits < 1) { throw new InvalidArgumentException('Batch yield must be a positive number of saleable units.'); } $scaled = FixedPoint::toScaled($grandTotal, self::AMOUNT_SCALE); return FixedPoint::format(FixedPoint::divideByInt($scaled, $batchUnits), self::AMOUNT_SCALE); } }