6 ) { throw new MoneyException( 'MB_MONEY_PRECISION_INVALID', 'money_scale must be 0–6' ); } if ( ! is_int( $units ) && ! ( is_string( $units ) && preg_match( '/^-?\d+$/', $units ) ) ) { throw new MoneyException( 'MB_MONEY_PRECISION_INVALID', 'units must be integer' ); } $this->units = is_int( $units ) ? $units : (string) $units; $this->scale = $scale; } /** * Parse a decimal string into MoneyAmount. * * @param string|null $value Decimal string e.g. "12.345". * @param int $scale Target scale. * @return MoneyAmount|null Null when value is null/empty (missing, not zero). */ public static function from_decimal_string( $value, $scale ) { if ( null === $value || '' === $value ) { return null; } if ( ! is_string( $value ) && ! is_int( $value ) && ! is_float( $value ) ) { throw new MoneyException( 'MB_MONEY_PRECISION_INVALID', 'unsupported money input type' ); } // Never accept float for calculation — convert via string only for display ingestion with warning path. if ( is_float( $value ) ) { throw new MoneyException( 'MB_MONEY_PRECISION_INVALID', 'floating point money input is forbidden' ); } $string = is_int( $value ) ? (string) $value : trim( (string) $value ); if ( ! preg_match( '/^-?\d+(\.\d+)?$/', $string ) ) { throw new MoneyException( 'MB_MONEY_PRECISION_INVALID', 'malformed decimal string' ); } $negative = false; if ( '-' === $string[0] ) { $negative = true; $string = substr( $string, 1 ); } $parts = explode( '.', $string, 2 ); $whole = $parts[0]; $frac = isset( $parts[1] ) ? $parts[1] : ''; if ( strlen( $frac ) > $scale ) { // Trailing zeros beyond scale are OK if they are only zeros; otherwise reject (no silent truncation). $extra = substr( $frac, $scale ); if ( preg_match( '/[^0]/', $extra ) ) { throw new MoneyException( 'MB_MONEY_PRECISION_INVALID', 'value exceeds money_scale losslessly' ); } $frac = substr( $frac, 0, $scale ); } $frac = str_pad( $frac, $scale, '0', STR_PAD_RIGHT ); $digits = $whole . $frac; $digits = ltrim( $digits, '0' ); if ( '' === $digits ) { $digits = '0'; } if ( $negative && '0' !== $digits ) { $digits = '-' . $digits; } if ( function_exists( 'bcadd' ) ) { // Normalize via bcmath path for consistency. } $units = self::to_int_or_string( $digits ); return new self( $units, $scale ); } /** * Zero amount at scale. * * @param int $scale Scale. * @return MoneyAmount */ public static function zero( $scale ) { return new self( 0, $scale ); } /** * @return int|string */ public function units() { return $this->units; } /** * @return int */ public function scale() { return $this->scale; } /** * Decimal string for DB DECIMAL storage (exact). * * @return string */ public function to_decimal_string() { $units = (string) $this->units; $negative = false; if ( '-' === $units[0] ) { $negative = true; $units = substr( $units, 1 ); } $units = str_pad( $units, $this->scale + 1, '0', STR_PAD_LEFT ); if ( 0 === $this->scale ) { return ( $negative ? '-' : '' ) . (string) (int) $units; } $whole = substr( $units, 0, -$this->scale ); $frac = substr( $units, -$this->scale ); $whole = '' === $whole ? '0' : $whole; return ( $negative ? '-' : '' ) . $whole . '.' . $frac; } /** * Whether amount is strictly negative. * * @return bool */ public function is_negative() { return self::cmp( $this->units, 0 ) < 0; } /** * Whether amount is zero. * * @return bool */ public function is_zero() { return 0 === self::cmp( $this->units, 0 ); } /** * Whether amount is strictly positive. * * @return bool */ public function is_positive() { return self::cmp( $this->units, 0 ) > 0; } /** * Compare two integer-like values. * * @param int|string $a Left. * @param int|string $b Right. * @return int -1|0|1 */ public static function cmp( $a, $b ) { Bcmath::require_extension(); return (int) bccomp( (string) $a, (string) $b, 0 ); } /** * @param string $digits Digit string. * @return int|string */ private static function to_int_or_string( $digits ) { if ( strlen( ltrim( $digits, '-' ) ) < 15 ) { return (int) $digits; } return $digits; } }