taler-ios

iOS apps for GNU Taler (wallet)
Log | Files | Refs | README | LICENSE

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 }