4, 'UYW' => 4 ) ); } /** Preserve fractional historical amounts even for whole-unit currencies. */ public static function display_precision( int $cents, string $currency ): int { $precision = self::precision( $currency ); return $precision === 0 && $cents % 100 !== 0 ? 2 : $precision; } /** Locale formats the number; ISO codes remain clear across PHP and JS. */ public static function format( int $cents, string $currency ): string { return Locale::currency( $cents / 100, strtoupper( trim( $currency ) ), self::display_precision( $cents, $currency ) ); } /** Whether a new amount can be written without rounding or rescaling. */ public static function representable( int $cents, string $currency ): bool { return $cents >= 0 && $cents <= 2147483647 && ( self::precision( $currency ) !== 0 || $cents % 100 === 0 ); } /** Shared appointment write guard; preserve unchanged retained and past imported prices. */ public static function validate_booking( array $after, ?array $before = null ): void { if ( $before && (int) $after['price_cents'] === (int) $before['price_cents'] && (string) $after['currency'] === (string) $before['currency'] ) { return; } if ( ! $before && ( $after['source'] ?? '' ) === 'import' ) { $end = \DateTimeImmutable::createFromFormat( '!Y-m-d H:i:s', (string) ( $after['end_utc'] ?? '' ), new \DateTimeZone( 'UTC' ) ); if ( $end && $end->format( 'Y-m-d H:i:s' ) === $after['end_utc'] && $end->getTimestamp() <= time() ) { return; } } self::validate( (int) $after['price_cents'], (string) $after['currency'] ); } /** Call on new monetary writes; do not apply to historical read access. */ public static function validate( int $cents, string $currency ): void { if ( $cents < 0 || $cents > 2147483647 ) { throw new \InvalidArgumentException( 'money_input' ); } if ( ! self::representable( $cents, $currency ) ) { throw new \InvalidArgumentException( 'money_precision' ); } } }