Amount.swift (9460B)
1 /* 2 * This file is part of GNU Taler 3 * (C) 2026 Bohdan, Volodymyr Potuzhnyi 4 * 5 * GNU Taler is free software; you can redistribute it and/or modify it under the 6 * terms of the GNU General Public License as published by the Free Software 7 * Foundation; either version 3, or (at your option) any later version. 8 * 9 * GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY 10 * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR 11 * A PARTICULAR PURPOSE. See the GNU General Public License for more details. 12 * 13 * You should have received a copy of the GNU General Public License along with 14 * GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> 15 */ 16 17 import Foundation 18 19 enum AmountError: Error { 20 case invalidCurrency 21 case invalidFormat 22 case overflow 23 case underflow 24 case currencyMismatch 25 } 26 27 struct Amount: Codable, Equatable, Hashable, Comparable { 28 let currency: String 29 let value: UInt64 30 let fraction: UInt32 31 32 static let fractionalBase: UInt32 = 100_000_000 33 static let maxFractionalDigits = 8 34 static let maxValue: UInt64 = (1 << 52) 35 private static let currencyRegex = try! NSRegularExpression(pattern: "^[-_*A-Za-z0-9]{1,12}$") 36 37 static func zero(_ currency: String) -> Amount { 38 Amount(currency: currency, value: 0, fraction: 0) 39 } 40 41 var isZero: Bool { 42 value == 0 && fraction == 0 43 } 44 45 init(currency: String, value: UInt64, fraction: UInt32 = 0) { 46 self.currency = currency 47 self.value = value 48 self.fraction = fraction 49 } 50 51 init(from decoder: Decoder) throws { 52 let container = try decoder.singleValueContainer() 53 let str = try container.decode(String.self) 54 guard let parsed = Amount.parse(str) else { 55 throw DecodingError.dataCorruptedError(in: container, debugDescription: "Invalid amount: \(str)") 56 } 57 self = parsed 58 } 59 60 func encode(to encoder: Encoder) throws { 61 var container = encoder.singleValueContainer() 62 try container.encode(description) 63 } 64 65 static func isValidCurrency(_ currency: String) -> Bool { 66 let range = NSRange(currency.startIndex..., in: currency) 67 return currencyRegex.firstMatch(in: currency, range: range) != nil 68 } 69 70 static func parse(_ str: String) -> Amount? { 71 let parts = str.split(separator: ":", maxSplits: 1) 72 guard parts.count == 2 else { return nil } 73 let currency = String(parts[0]) 74 guard isValidCurrency(currency) else { return nil } 75 let valueParts = parts[1].split(separator: ".", maxSplits: 1) 76 guard let value = UInt64(valueParts[0]) else { return nil } 77 guard value <= maxValue else { return nil } 78 var fraction: UInt32 = 0 79 if valueParts.count == 2 { 80 let fracStr = String(valueParts[1]) 81 guard fracStr.count <= maxFractionalDigits else { return nil } 82 guard fracStr.allSatisfy(\.isNumber) else { return nil } 83 var padded = fracStr 84 while padded.count < 8 { padded += "0" } 85 guard let f = UInt32(padded) else { return nil } 86 fraction = f 87 } 88 return Amount(currency: currency, value: value, fraction: fraction) 89 } 90 91 static func fromString(_ currency: String, _ amountStr: String) -> Amount? { 92 let trimmed = amountStr.trimmingCharacters(in: .whitespaces) 93 if trimmed.isEmpty { return nil } 94 let valueParts = trimmed.split(separator: ".", maxSplits: 1) 95 guard let intPart = UInt64(valueParts[0]) else { return nil } 96 guard intPart <= maxValue else { return nil } 97 var fraction: UInt32 = 0 98 if valueParts.count == 2 { 99 let fracStr = String(valueParts[1]) 100 guard fracStr.count <= maxFractionalDigits else { return nil } 101 guard fracStr.allSatisfy(\.isNumber) else { return nil } 102 var padded = fracStr 103 while padded.count < 8 { padded += "0" } 104 guard let f = UInt32(padded) else { return nil } 105 fraction = f 106 } 107 return Amount(currency: currency, value: intPart, fraction: fraction) 108 } 109 110 func addInputDigit(_ digit: Int, numInputDigits: Int) -> Amount? { 111 let digitCount = numInputDigits > 0 ? numInputDigits : 2 112 let fracBase = Self.pow10(digitCount) 113 let totalUnits = UInt64(value) * UInt64(fracBase) + UInt64(fraction) / (UInt64(Self.fractionalBase) / UInt64(fracBase)) 114 let shifted = totalUnits * 10 + UInt64(digit) 115 let newValue = shifted / UInt64(fracBase) 116 guard newValue <= Self.maxValue else { return nil } 117 let newFracSmall = shifted % UInt64(fracBase) 118 let newFraction = UInt32(newFracSmall) * (Self.fractionalBase / UInt32(fracBase)) 119 return Amount(currency: currency, value: newValue, fraction: newFraction) 120 } 121 122 // Integer-only keypad digit removal (no floating point) 123 func removeInputDigit(numInputDigits: Int) -> Amount { 124 let digitCount = numInputDigits > 0 ? numInputDigits : 2 125 let fracBase = Self.pow10(digitCount) 126 let totalUnits = UInt64(value) * UInt64(fracBase) + UInt64(fraction) / (UInt64(Self.fractionalBase) / UInt64(fracBase)) 127 let shifted = totalUnits / 10 128 let newValue = shifted / UInt64(fracBase) 129 let newFracSmall = shifted % UInt64(fracBase) 130 let newFraction = UInt32(newFracSmall) * (Self.fractionalBase / UInt32(fracBase)) 131 return Amount(currency: currency, value: newValue, fraction: newFraction) 132 } 133 134 private static func pow10(_ n: Int) -> UInt32 { 135 var result: UInt32 = 1 136 for _ in 0..<n { result *= 10 } 137 return result 138 } 139 140 static func + (lhs: Amount, rhs: Amount) -> Amount { 141 precondition(lhs.currency == rhs.currency, "Currency mismatch: \(lhs.currency) vs \(rhs.currency)") 142 var fraction = UInt64(lhs.fraction) + UInt64(rhs.fraction) 143 var value = lhs.value + rhs.value 144 if fraction >= UInt64(fractionalBase) { 145 value += 1 146 fraction -= UInt64(fractionalBase) 147 } 148 precondition(value <= maxValue, "Amount overflow") 149 return Amount(currency: lhs.currency, value: value, fraction: UInt32(fraction)) 150 } 151 152 static func - (lhs: Amount, rhs: Amount) -> Amount { 153 precondition(lhs.currency == rhs.currency, "Currency mismatch: \(lhs.currency) vs \(rhs.currency)") 154 var fraction = Int64(lhs.fraction) - Int64(rhs.fraction) 155 var value = Int64(lhs.value) - Int64(rhs.value) 156 if fraction < 0 { 157 value -= 1 158 fraction += Int64(fractionalBase) 159 } 160 precondition(value >= 0, "Amount underflow") 161 return Amount(currency: lhs.currency, value: UInt64(value), fraction: UInt32(fraction)) 162 } 163 164 static func * (lhs: Amount, rhs: Int) -> Amount { 165 var result = Amount.zero(lhs.currency) 166 for _ in 0..<rhs { 167 result = result + lhs 168 } 169 return result 170 } 171 172 static func < (lhs: Amount, rhs: Amount) -> Bool { 173 precondition(lhs.currency == rhs.currency, "Currency mismatch: \(lhs.currency) vs \(rhs.currency)") 174 if lhs.value != rhs.value { return lhs.value < rhs.value } 175 return lhs.fraction < rhs.fraction 176 } 177 178 static func > (lhs: Amount, rhs: Amount) -> Bool { 179 precondition(lhs.currency == rhs.currency, "Currency mismatch: \(lhs.currency) vs \(rhs.currency)") 180 if lhs.value != rhs.value { return lhs.value > rhs.value } 181 return lhs.fraction > rhs.fraction 182 } 183 } 184 185 extension Amount: CustomStringConvertible { 186 var description: String { 187 let fracStr = String(format: "%08u", fraction) 188 let trimmed = fracStr.replacingOccurrences(of: "0+$", with: "", options: .regularExpression) 189 if trimmed.isEmpty { 190 return "\(currency):\(value)" 191 } 192 return "\(currency):\(value).\(trimmed)" 193 } 194 195 var readableAmount: String { 196 readableAmount(numDigits: 2) 197 } 198 199 func readableAmount(numDigits: Int) -> String { 200 let digits = max(numDigits, 0) 201 if digits == 0 { return "\(value) \(currency)" } 202 let fracStr = String(format: "%08u", fraction) 203 let trimmed = String(fracStr.prefix(digits)) 204 return "\(value).\(trimmed) \(currency)" 205 } 206 207 func readableAmount(spec: CurrencySpecification?) -> String { 208 guard let spec else { return readableAmount(numDigits: 2) } 209 let maxDigits = spec.displayDigits 210 let minDigits = spec.trailingZeroDigits 211 212 let formatter = NumberFormatter() 213 formatter.numberStyle = .decimal 214 formatter.minimumFractionDigits = minDigits 215 formatter.maximumFractionDigits = max(maxDigits, minDigits) 216 formatter.usesGroupingSeparator = true 217 218 let decimalValue = Decimal(value) + Decimal(fraction) / Decimal(Amount.fractionalBase) 219 let formatted = formatter.string(from: decimalValue as NSDecimalNumber) ?? "\(value)" 220 221 if let symbol = spec.symbol { 222 return "\(symbol)\u{00A0}\(formatted)" 223 } 224 return "\(formatted) \(currency)" 225 } 226 227 func amountStr(numDigits: Int = 2) -> String { 228 let digits = max(numDigits, 0) 229 if digits == 0 { return "\(value)" } 230 let fracStr = String(format: "%08u", fraction) 231 let trimmed = String(fracStr.prefix(digits)) 232 return "\(value).\(trimmed)" 233 } 234 }