turnstile

Drupal paywall plugin
Log | Files | Refs | README | LICENSE

Amount.php (7290B)


      1 <?php
      2 
      3 /**
      4  * @file
      5  * Location: src/Amount.php
      6  *
      7  * Discrete representation of a GNU Taler monetary amount.
      8  */
      9 
     10 namespace Drupal\taler_turnstile;
     11 
     12 /**
     13  * A GNU Taler monetary amount, stored as a (currency, value, fraction)
     14  * triple of discrete integers rather than a float.
     15  *
     16  * Taler mandates that the fractional part is a non-negative integer
     17  * strictly less than 10^8, i.e. up to eight decimal digits. Float
     18  * arithmetic violates this invariant (e.g. 0.1 + 0.2 = 0.30000…04),
     19  * which is why every price computation that crosses an addition must
     20  * go through this class.
     21  *
     22  * Canonical wire form is "CURRENCY:VALUE" when the fraction is zero,
     23  * otherwise "CURRENCY:VALUE.FRACTION" with trailing zeros stripped
     24  * from the fractional part.
     25  */
     26 final class Amount implements \Stringable {
     27 
     28   /**
     29    * Number of fractional units per integer unit (10^8).
     30    * Defined by the GNU Taler amount specification.
     31    */
     32   const FRAC_BASE = 100000000;
     33 
     34   /**
     35    * Maximum number of decimal digits in the fractional part.
     36    */
     37   const FRAC_DIGITS = 8;
     38 
     39   public readonly string $currency;
     40   public readonly int    $value;
     41   public readonly int    $fraction;
     42 
     43   /**
     44    * @param string $currency  Non-empty currency code (e.g. "EUR").
     45    * @param int    $value     Integer part, must be >= 0.
     46    * @param int    $fraction  Fractional part in 1/10^8 units,
     47    *                          must be in [0, FRAC_BASE).
     48    */
     49   public function __construct(string $currency, int $value, int $fraction) {
     50     if ($currency === '') {
     51       throw new \InvalidArgumentException('Amount: currency must be non-empty.');
     52     }
     53     if ($value < 0) {
     54       throw new \InvalidArgumentException("Amount: value must be non-negative, got $value.");
     55     }
     56     if ($fraction < 0 || $fraction >= self::FRAC_BASE) {
     57       throw new \InvalidArgumentException(
     58         "Amount: fraction must be in [0, " . self::FRAC_BASE . "), got $fraction.");
     59     }
     60     $this->currency = $currency;
     61     $this->value    = $value;
     62     $this->fraction = $fraction;
     63   }
     64 
     65 
     66   /**
     67    * Parse a canonical Taler amount string ("CUR:V" or "CUR:V.F").
     68    *
     69    * @throws \InvalidArgumentException if the input is not a well-formed
     70    *   Taler amount or its fraction exceeds 8 digits.
     71    */
     72   public static function parse(string $s): self {
     73     if (!preg_match('/^([A-Za-z][A-Za-z0-9_-]*):(\d+)(?:\.(\d{1,8}))?$/', $s, $m)) {
     74       throw new \InvalidArgumentException("Malformed Taler amount: $s");
     75     }
     76     return new self($m[1], (int) $m[2], self::parseFraction($m[3] ?? ''));
     77   }
     78 
     79 
     80   /**
     81    * Build an Amount from a currency code and a non-negative decimal
     82    * numeric input (string, int, or float). The numeric value must be
     83    * a plain decimal — no scientific notation, no sign, no thousands
     84    * separators. Up to 8 fractional digits are accepted; longer inputs
     85    * are rejected rather than silently truncated.
     86    *
     87    * Floats are accepted for convenience but are cast via PHP's default
     88    * stringification, which can introduce precision artefacts; callers
     89    * that have a string already (e.g. straight out of a form value)
     90    * should pass that string directly.
     91    */
     92   public static function fromNumeric(string $currency, string|int|float $numeric): self {
     93     $s = is_string($numeric) ? trim($numeric) : (string) $numeric;
     94     if (!preg_match('/^(\d+)(?:\.(\d{1,8}))?$/', $s, $m)) {
     95       throw new \InvalidArgumentException(
     96         "Cannot interpret '$s' as a non-negative decimal Taler amount.");
     97     }
     98     return new self($currency, (int) $m[1], self::parseFraction($m[2] ?? ''));
     99   }
    100 
    101 
    102   /**
    103    * Right-pad a fractional digit string to FRAC_DIGITS and return its
    104    * integer value. Empty input yields 0.
    105    */
    106   private static function parseFraction(string $s): int {
    107     if ($s === '') {
    108       return 0;
    109     }
    110     return (int) str_pad($s, self::FRAC_DIGITS, '0', STR_PAD_RIGHT);
    111   }
    112 
    113 
    114   /**
    115    * Return the smallest positive amount in $currency expressible
    116    * with $digits fractional digits:
    117    *
    118    *   step(c, 0) -> c:1
    119    *   step(c, 1) -> c:0.1
    120    *   step(c, 2) -> c:0.01
    121    *   step(c, 8) -> c:0.00000001
    122    *
    123    * Use this instead of pow(0.1, $digits) for anything that compares
    124    * or renders the result — pow(0.1, 2) is 0.010000000000000002,
    125    * which is wrong as a currency step.
    126    *
    127    * @throws \InvalidArgumentException if $digits is outside
    128    *   [0, FRAC_DIGITS].
    129    */
    130   public static function step(string $currency, int $digits): self {
    131     if ($digits < 0 || $digits > self::FRAC_DIGITS) {
    132       throw new \InvalidArgumentException(
    133         "Amount::step: digits must be in [0, " . self::FRAC_DIGITS . "], got $digits.");
    134     }
    135     if ($digits === 0) {
    136       return new self($currency, 1, 0);
    137     }
    138     // FRAC_BASE = 10^FRAC_DIGITS, so the smallest unit at $digits
    139     // fractional digits sits at fraction = 10^(FRAC_DIGITS - $digits).
    140     // All exponents in [0, 8] yield results that fit comfortably in
    141     // PHP's int, so the (int) cast on integer-valued pow() is exact.
    142     return new self($currency, 0, (int) (10 ** (self::FRAC_DIGITS - $digits)));
    143   }
    144 
    145 
    146   /**
    147    * Add another Amount. Returns a new Amount; this instance is
    148    * unchanged. Both Amounts must use the same currency.
    149    *
    150    * @throws \DomainException on currency mismatch.
    151    */
    152   public function add(Amount $other): self {
    153     if ($this->currency !== $other->currency) {
    154       throw new \DomainException(sprintf(
    155         'Cannot add Taler amounts of different currencies (%s vs %s).',
    156         $this->currency,
    157         $other->currency
    158       ));
    159     }
    160     $fraction = $this->fraction + $other->fraction;
    161     $value    = $this->value + $other->value;
    162     if ($fraction >= self::FRAC_BASE) {
    163       $fraction -= self::FRAC_BASE;
    164       $value++;
    165     }
    166     return new self($this->currency, $value, $fraction);
    167   }
    168 
    169 
    170   /**
    171    * Structural equality. Two Amounts compare equal iff currency,
    172    * value, and fraction all match exactly.
    173    */
    174   public function equals(Amount $other): bool {
    175     return $this->currency === $other->currency
    176         && $this->value    === $other->value
    177         && $this->fraction === $other->fraction;
    178   }
    179 
    180 
    181   /**
    182    * Render just the numeric part — "V" when the fraction is zero,
    183    * otherwise "V.F" with trailing zeros stripped from F. The currency
    184    * code is NOT included; use this when feeding callers (HTML form
    185    * widgets, etc.) that want a bare decimal.
    186    *
    187    * Because the fraction==0 case is handled by an early return, the
    188    * decimal-point branch always has at least one non-zero fractional
    189    * digit and the rtrim() is safe.
    190    */
    191   public function numericString(): string {
    192     if ($this->fraction === 0) {
    193       return (string) $this->value;
    194     }
    195     $frac = rtrim(
    196       str_pad((string) $this->fraction, self::FRAC_DIGITS, '0', STR_PAD_LEFT),
    197       '0'
    198     );
    199     return $this->value . '.' . $frac;
    200   }
    201 
    202 
    203   /**
    204    * Render in canonical Taler form, kept as short as possible:
    205    *
    206    *   - fraction == 0  -> "CUR:V"        (no decimal point, never "CUR:V.0")
    207    *   - fraction != 0  -> "CUR:V.F"      with trailing zeros stripped
    208    *                                      from F ("EUR:0.5", not
    209    *                                      "EUR:0.50000000").
    210    */
    211   public function __toString(): string {
    212     return $this->currency . ':' . $this->numericString();
    213   }
    214 
    215 }