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 }