1000000000) { throw new \InvalidArgumentException('Invalid monetary amount.'); } if ($decimals < 0 || $decimals > 4) { throw new \InvalidArgumentException('Unsupported currency precision.'); } return (int) round((float) $value * pow(10, $decimals), 0, PHP_ROUND_HALF_UP); } /** Canonical bounded percentage; reject precision we cannot preserve. */ public static function normalize_percentage($percent): string { if ((!is_string($percent) && !is_int($percent)) || !preg_match('/^(?:[0-9]+(?:\.[0-9]+)?|\.[0-9]+)$/D', (string) $percent)) { throw new \InvalidArgumentException('Use a percentage from 0 to 100 with at most 12 decimal places.'); } $parts = explode('.', (string) $percent); $whole = ltrim($parts[0], '0') ?: '0'; $fraction = rtrim($parts[1] ?? '', '0'); if (strlen($whole) > 3 || (int) $whole > 100 || ((int) $whole === 100 && $fraction !== '') || strlen($fraction) > 12) { throw new \InvalidArgumentException('Use a percentage from 0 to 100 with at most 12 decimal places.'); } return $whole . ($fraction === '' ? '' : '.' . $fraction); } /** Exact decimal percentage of non-negative integer money; no float conversion. * Ceiling is used when a whole minor unit is required to meet a margin target. * Multiplication is bounded to 19 money digits and 15 percentage digits. */ public static function percentage(int $amount, $percent, bool $ceiling = false): int { if ($amount < 0) { throw new \InvalidArgumentException('Invalid monetary amount.'); } $parts = explode('.', self::normalize_percentage($percent)); $whole = $parts[0]; $fraction = $parts[1] ?? ''; $digits = ltrim($whole . $fraction, '0') ?: '0'; if ($amount === 0 || $digits === '0') { return 0; } $money = (string) $amount; $product = array_fill(0, strlen($money) + strlen($digits), 0); for ($i = strlen($money) - 1; $i >= 0; $i--) { for ($j = strlen($digits) - 1; $j >= 0; $j--) { $product[$i + $j + 1] += (int) $money[$i] * (int) $digits[$j]; } } for ($i = count($product) - 1; $i > 0; $i--) { $product[$i - 1] += intdiv($product[$i], 10); $product[$i] %= 10; } $scale = strlen($fraction) + 2; $product = str_pad(ltrim(implode('', $product), '0'), $scale + 1, '0', STR_PAD_LEFT); $units = (int) substr($product, 0, -$scale); $remainder = substr($product, -$scale); $up = $ceiling ? strspn($remainder, '0') !== strlen($remainder) : $remainder[0] >= '5'; // With a percentage <=100, rounding never exceeds the input amount. return $units + ($up ? 1 : 0); } private static function basis_points(int $value): string { return intdiv($value, 100) . '.' . str_pad((string) ($value % 100), 2, '0', STR_PAD_LEFT); } /** Money inputs are integer minor units; 100 fee basis points = one percent. */ public static function calculate(array $in): array { foreach (['gross','tax','refund_gross','refund_tax','cogs','recovered_cogs','shipping_cost','packaging_cost','fixed_fee'] as $key) { if (!array_key_exists($key, $in) || !is_int($in[$key]) || $in[$key] < 0) { throw new \InvalidArgumentException('Every required money input must be a non-negative integer.'); } } $rate = $in['fee_bps'] ?? 0; $threshold = $in['threshold_bps'] ?? 1500; if (!is_int($rate) || $rate < 0 || $rate > 10000 || !is_int($threshold) || $threshold < 0 || $threshold > 10000) { throw new \InvalidArgumentException('Invalid percentage.'); } $issues = array_values(array_unique($in['issues'] ?? [])); if ($in['tax'] > $in['gross'] || $in['refund_tax'] > $in['refund_gross'] || $in['refund_tax'] > $in['tax'] || $in['refund_gross'] > $in['gross']) { $issues[] = 'refund_or_tax_mismatch'; } if ($in['recovered_cogs'] > $in['cogs']) { $issues[] = 'recovery_exceeds_cost'; } if ($in['recovered_cogs'] > 0 && $in['refund_gross'] === 0) { $issues[] = 'recovery_without_refund'; } if ($in['refund_gross'] > 0 && empty($in['refund_reviewed'])) { $issues[] = 'refund_needs_review'; } $fee_percent = $in['fee_percent'] ?? self::basis_points($rate); $threshold_percent = $in['threshold_percent'] ?? self::basis_points($threshold); if (isset($in['fee_override'])) { if (!is_int($in['fee_override']) || $in['fee_override'] < 0) { throw new \InvalidArgumentException('Invalid payment cost.'); } $fee = $in['fee_override']; } else { $fee = self::percentage($in['gross'], $fee_percent) + $in['fixed_fee']; } $net_refund = $in['refund_gross'] - $in['refund_tax']; $revenue = $in['gross'] - $in['tax'] - $net_refund; if ($revenue < 0) { $issues[] = 'refund_or_tax_mismatch'; } $net_cogs = $in['cogs'] - $in['recovered_cogs']; $profit = $revenue - $net_cogs - $fee - $in['shipping_cost'] - $in['packaging_cost']; $issues = array_values(array_unique($issues)); $ready = count($issues) === 0; $margin = $revenue > 0 ? round($profit / $revenue * 100, 2) : null; $target = self::percentage(max(0, $revenue), $threshold_percent, true); $state = !$ready ? 'review' : ($profit < 0 ? 'loss' : (($margin !== null && $profit < $target) ? 'low' : 'healthy')); return [ 'revenue' => $revenue, 'gross' => $in['gross'], 'tax' => $in['tax'], 'refund_net' => $net_refund, 'cogs' => $in['cogs'], 'recovered_cogs' => $in['recovered_cogs'], 'net_cogs' => $net_cogs, 'payment_cost' => $fee, 'shipping_cost' => $in['shipping_cost'], 'packaging_cost' => $in['packaging_cost'], 'profit' => $ready ? $profit : null, 'margin' => $ready ? $margin : null, 'state' => $state, 'issues' => $issues, 'ready' => $ready, 'gap_to_target' => $ready ? max(0, $target - $profit) : null, ]; } /** Allocate an order-level amount exactly using largest remainders. */ public static function allocate(int $amount, array $weights): array { if ($amount === PHP_INT_MIN) { throw new \InvalidArgumentException('Allocation amount is outside the supported range.'); } $sum = 0; foreach ($weights as $weight) { if (!is_int($weight) || $weight < 0) { throw new \InvalidArgumentException('Allocation weights must be non-negative integers.'); } if ($weight > PHP_INT_MAX - $sum) { throw new \OverflowException('Allocation weights exceed the supported range.'); } $sum += $weight; } $out = array_fill_keys(array_keys($weights), 0); if (!$weights || $sum <= 0) { return $out; } $sign = $amount < 0 ? -1 : 1; $amount = abs($amount); $whole = intdiv($amount, $sum); $fraction = $amount % $sum; $remainders = []; $used = 0; foreach ($weights as $key => $weight) { [$part, $remainder] = self::fraction($fraction, $weight, $sum); $out[$key] = $whole * $weight + $part; $used += $out[$key]; $remainders[$key] = $remainder; } // Integer comparison avoids converting nearly equal large remainders to // floats. Preserve input order for ties on PHP 7.4 as well as PHP 8. $keys = array_keys($remainders); $positions = array_flip($keys); usort($keys, static function ($a, $b) use ($remainders, $positions) { return ($remainders[$b] <=> $remainders[$a]) ?: ($positions[$a] <=> $positions[$b]); }); foreach ($keys as $key) { if ($used >= $amount) { break; } $out[$key]++; $used++; } return array_map(static function ($value) use ($sign) { return $value * $sign; }, $out); } /** Exact quotient/remainder of a*b/divisor, with a 0) { if ($b % 2) { $quotient += $part; if ($remainder >= $divisor - $rest) { $remainder -= $divisor - $rest; $quotient++; } else { $remainder += $rest; } } $b = intdiv($b, 2); if (!$b) { break; } $part *= 2; if ($rest >= $divisor - $rest) { $rest -= $divisor - $rest; $part++; } else { $rest += $rest; } } return [$quotient, $remainder]; } }