wikimedia/mediawiki-extensions-DonationInterface

View on GitHub
gateway_common/Amount.php

Summary

Maintainability
A
3 hrs
Test Coverage
<?php

use SmashPig\Core\ValidationError;
use SmashPig\PaymentData\ReferenceData\CurrencyRates;

class Amount implements ValidationHelper {

    public function validate( GatewayType $adapter, $normalized, &$errors ) {
        if (
            !isset( $normalized['amount'] ) ||
            !isset( $normalized['currency'] )
        ) {
            // Not enough info to validate
            return;
        }
        if ( $errors->hasValidationError( 'currency' ) ) {
            // Already displaying an error
            return;
        }
        $value = $normalized['amount'];

        if ( self::isZeroIsh( $value ) ) {
            $errors->addError(
                DataValidator::getError( 'amount', 'not_empty' )
            );
            return;
        }
        $donationCurrency = $normalized['currency'];
        $rules = $adapter->getDonationRules();
        if ( $rules ) {
            $ruleCurrency = $rules['currency'];
            if ( $ruleCurrency === $donationCurrency ) {
                // Avoid converting if we're already looking at the right currency
                $min = $rules['min'];
                $max = $rules['max'];
                $maxUsd = self::convert( $max, 'USD', $ruleCurrency );
            } else {
                // Get the min and max in USD, then convert to donation currency
                $minUsd = self::convert( $rules['min'], 'USD', $ruleCurrency );
                $maxUsd = self::convert( $rules['max'], 'USD', $ruleCurrency );
                $min = self::convert( $minUsd, $donationCurrency );
                $max = self::convert( $maxUsd, $donationCurrency );
            }
            if (
                !is_numeric( $value ) ||
                $value < 0
            ) {
                $errors->addError( new ValidationError(
                    'amount',
                    'donate_interface-error-msg-invalid-amount'
                ) );
            } elseif ( $value > $max ) {
                // FIXME: should format the currency values in this message
                $errors->addError( new ValidationError(
                    'amount',
                    'donate_interface-bigamount-error',
                    [
                        $max,
                        $donationCurrency,
                        $adapter->getGlobal( 'MajorGiftsEmail' ),
                        self::round( $maxUsd, 'USD' )
                    ]
                ) );
            } elseif ( $value < $min ) {
                $locale = $normalized['language'] . '_' . $normalized['country'];
                $formattedMin = self::format( $min, $donationCurrency, $locale );
                $errors->addError( new ValidationError(
                    'amount',
                    'donate_interface-smallamount-error',
                    [ $formattedMin ]
                ) );
            }
        }
    }

    /**
     * Checks if the $value is missing or equivalent to zero.
     *
     * @param string $value The value to check for zero-ness
     * @return bool True if the $value is missing or zero, otherwise false
     */
    protected static function isZeroIsh( $value ) {
        if (
            $value === null ||
            trim( $value ) === '' ||
            ( is_numeric( $value ) && abs( (float)$value ) < 0.01 )
        ) {
            return true;
        }

        return false;
    }

    /**
     * Convert an amount in USD to a particular currency
     *
     * This is grossly rudimentary and likely wildly inaccurate.
     * This mimics the hard-coded values used by the WMF to convert currencies
     * for validation on the front-end on the first step landing pages of their
     * donation process - the idea being that we can get a close approximation
     * of converted currencies to ensure that contributors are not going above
     * or below the price ceiling/floor, even if they are using a non-US currency.
     *
     * In reality, this probably ought to use some sort of webservice to get real-time
     * conversion rates.
     *
     * @param float $amount
     * @param string $toCurrency
     * @param string $fromCurrency
     * @return float
     * @throws UnexpectedValueException
     */
    public static function convert( $amount, $toCurrency, $fromCurrency = 'USD' ) {
        $rates = CurrencyRates::getCurrencyRates();
        $code = strtoupper( $toCurrency );
        if ( array_key_exists( $code, $rates ) ) {
            return $amount / $rates[$fromCurrency] * $rates[$code];
        }
        throw new UnexpectedValueException(
            'Bad programmer!  Bad currency made it too far through the portcullis'
        );
    }

    /**
     * Some currencies, like JPY, don't exist in fractional amounts.
     * This rounds an amount to the appropriate number of decimal places.
     * Use the results of this for internal use, and use @see Amount::format
     * for values displayed to donors.
     *
     * @param float $amount
     * @param string $currencyCode
     * @return string rounded amount
     */
    public static function round( $amount, $currencyCode ) {
        $amount = floatval( $amount );
        if ( static::is_fractional_currency( $currencyCode ) ) {
            $precision = 2;
            if ( self::is_exponent3_currency( $currencyCode ) ) {
                $precision = 3;
            }
            return number_format( $amount, $precision, '.', '' );
        } else {
            return (string)floor( $amount );
        }
    }

    /**
     * If an amount is ever expressed for the fractional currencies defined in
     * this function, they should not have an associated fractional amount
     * (so: full integers only).
     *
     * @param string $currency_code The three-digit currency code.
     * @return bool
     */
    public static function is_fractional_currency( $currency_code ) {
        // these currencies cannot have cents.
        $non_fractional_currencies = [
            'CLP', 'DJF', 'IDR', 'JPY', 'KMF', 'KRW', 'MGA', 'PYG', 'VND', 'XAF', 'XOF', 'XPF'
        ];

        if ( in_array( strtoupper( $currency_code ?? '' ), $non_fractional_currencies ) ) {
            return false;
        }
        return true;
    }

    /**
     * Checks if ISO 4217 defines the currency's minor units as being expressed using
     * exponent 3 (three decimal places).
     * @param string $currency_code The three-character currency code.
     * @return bool
     */
    public static function is_exponent3_currency( $currency_code ) {
        $exponent3_currencies = [ 'BHD', 'CLF', 'IQD', 'KWD', 'LYD', 'MGA', 'MRO', 'OMR', 'TND' ];

        if ( in_array( strtoupper( $currency_code ?? '' ), $exponent3_currencies ) ) {
            return true;
        }
        return false;
    }

    /**
     * Format an amount and currency for display to users.
     *
     * @param float $amount
     * @param string $currencyCode
     * @param string $locale e.g. en_US
     * @return string
     */
    public static function format( $amount, $currencyCode, $locale ) {
        $amount = self::round( $amount, $currencyCode );
        if ( class_exists( NumberFormatter::class ) ) {
            $formatter = new NumberFormatter( $locale, NumberFormatter::CURRENCY );

            return $formatter->formatCurrency(
                floatval( $amount ),
                $currencyCode
            );
        }
        return "$amount $currencyCode";
    }
}