import Foundation enum CurrencyFormatter { static func currentCurrencyCode() -> String { let context = CoreDataStack.shared.viewContext return AppSettings.getOrCreate(in: context).currency } static func locale(for currencyCode: String?) -> Locale { guard let currencyCode, !currencyCode.isEmpty else { return Locale.current } if let match = Locale.availableIdentifiers.first(where: { Locale(identifier: $0).currency?.identifier == currencyCode }) { return Locale(identifier: match) } return Locale.current } static func format(_ decimal: Decimal, style: NumberFormatter.Style = .currency, maximumFractionDigits: Int = 2) -> String { let formatter = NumberFormatter() formatter.numberStyle = style formatter.currencyCode = currentCurrencyCode() formatter.maximumFractionDigits = maximumFractionDigits return formatter.string(from: decimal as NSDecimalNumber) ?? "\(decimal)" } static func format( _ decimal: Decimal, currencyCode: String?, style: NumberFormatter.Style = .currency, maximumFractionDigits: Int = 2, preferredLocale: Locale? = nil ) -> String { let formatter = NumberFormatter() formatter.numberStyle = style formatter.currencyCode = currencyCode ?? currentCurrencyCode() formatter.maximumFractionDigits = maximumFractionDigits formatter.locale = preferredLocale ?? locale(for: currencyCode) return formatter.string(from: decimal as NSDecimalNumber) ?? "\(decimal)" } static func symbol(for code: String) -> String { let formatter = NumberFormatter() formatter.numberStyle = .currency formatter.currencyCode = code return formatter.currencySymbol ?? code } /// Parses a user-typed numeric string accepting both `.` and `,` as decimal/grouping separators. /// The parser is intentionally permissive to avoid turning decimal input into huge integers. static func parseUserInput(_ string: String, currencySymbol: String = "") -> Decimal? { let stripped = string .replacingOccurrences(of: currencySymbol, with: "") .replacingOccurrences(of: "\u{00A0}", with: " ") .trimmingCharacters(in: .whitespacesAndNewlines) .filter { $0.isNumber || $0 == "." || $0 == "," || $0 == "-" } guard !stripped.isEmpty else { return nil } let isNegative = stripped.hasPrefix("-") let unsigned = stripped.replacingOccurrences(of: "-", with: "") guard !unsigned.isEmpty else { return nil } let normalizedUnsigned = normalizeNumericInput(unsigned) let normalized = isNegative ? "-\(normalizedUnsigned)" : normalizedUnsigned let formatter = NumberFormatter() formatter.numberStyle = .decimal formatter.locale = Locale(identifier: "en_US_POSIX") return formatter.number(from: normalized)?.decimalValue } private static func normalizeNumericInput(_ input: String) -> String { let hasDot = input.contains(".") let hasComma = input.contains(",") if hasDot && hasComma { // Both separators present: the last one is considered decimal. let lastDot = input.lastIndex(of: ".")! let lastComma = input.lastIndex(of: ",")! if lastComma > lastDot { return input .replacingOccurrences(of: ".", with: "") .replacingOccurrences(of: ",", with: ".") } return input.replacingOccurrences(of: ",", with: "") } if hasDot { return normalizeSingleSeparator(input, separator: ".") } if hasComma { return normalizeSingleSeparator(input, separator: ",") } return input } private static func normalizeSingleSeparator(_ input: String, separator: Character) -> String { let parts = input.split(separator: separator, omittingEmptySubsequences: false) guard parts.count > 1 else { return input } if parts.count == 2 { // A single separator is treated as decimal (e.g. 533.595). return "\(parts[0]).\(parts[1])" } let integerParts = Array(parts.dropLast()) let fractionPart = String(parts.last ?? "") // If all groups follow a strict thousands pattern and the last one has 3 digits, // prefer grouping-only interpretation (e.g. 1.234.567). if looksLikeGroupedThousands(integerParts), fractionPart.count == 3 { return input.replacingOccurrences(of: String(separator), with: "") } // Otherwise, treat the last separator as decimal and previous ones as grouping. let integer = integerParts.joined() return "\(integer).\(fractionPart)" } private static func looksLikeGroupedThousands(_ groups: [Substring]) -> Bool { guard let first = groups.first, !first.isEmpty, first.count <= 3 else { return false } guard groups.count >= 2 else { return false } return groups.dropFirst().allSatisfy { $0.count == 3 } } /// Formats a decimal for display in an input field (no grouping separator). static func formatForInput(_ decimal: Decimal, currencyCode: String) -> String { let formatter = NumberFormatter() formatter.numberStyle = .decimal formatter.locale = locale(for: currencyCode) formatter.minimumFractionDigits = 2 formatter.maximumFractionDigits = 2 formatter.groupingSeparator = "" return formatter.string(from: decimal as NSDecimalNumber) ?? "" } }