PosConfig.swift (12651B)
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 import UIKit 19 20 struct MerchantConfig: Codable { 21 let baseUrl: String 22 let apiKey: String 23 24 enum CodingKeys: String, CodingKey { 25 case baseUrl = "base_url" 26 case apiKey = "api_key" 27 } 28 29 func urlFor(_ endpoint: String) -> String { 30 var base = baseUrl 31 if !base.hasSuffix("/") { base += "/" } 32 return base + endpoint 33 } 34 35 func urlForPathComponent(_ prefix: String, segment: String) -> String { 36 var base = baseUrl 37 if !base.hasSuffix("/") { base += "/" } 38 let encoded = segment.addingPercentEncoding(withAllowedCharacters: .urlPathComponentAllowed) ?? segment 39 return base + prefix + "/" + encoded 40 } 41 } 42 43 struct CurrencySpecification: Codable, Equatable { 44 let name: String? 45 let numFractionalInputDigits: Int? 46 let numFractionalNormalDigits: Int? 47 let numFractionalTrailingZeroDigits: Int? 48 let altUnitNames: [String: String]? 49 50 var inputDigits: Int { numFractionalInputDigits ?? 2 } 51 var displayDigits: Int { numFractionalNormalDigits ?? 2 } 52 var trailingZeroDigits: Int { numFractionalTrailingZeroDigits ?? 2 } 53 var symbol: String? { altUnitNames?["0"] } 54 55 enum CodingKeys: String, CodingKey { 56 case name 57 case numFractionalInputDigits = "num_fractional_input_digits" 58 case numFractionalNormalDigits = "num_fractional_normal_digits" 59 case numFractionalTrailingZeroDigits = "num_fractional_trailing_zero_digits" 60 case altUnitNames = "alt_unit_names" 61 } 62 } 63 64 struct PosConfig: Codable { 65 let categories: [Category] 66 let products: [ConfigProduct] 67 } 68 69 struct Category: Codable, Identifiable, Hashable { 70 let id: Int 71 let name: String 72 let nameI18n: [String: String]? 73 var selected: Bool = false 74 75 enum CodingKeys: String, CodingKey { 76 case id, name 77 case nameI18n = "name_i18n" 78 } 79 80 init(id: Int, name: String, nameI18n: [String: String]? = nil, selected: Bool = false) { 81 self.id = id 82 self.name = name 83 self.nameI18n = nameI18n 84 self.selected = selected 85 } 86 87 init(from decoder: Decoder) throws { 88 let container = try decoder.container(keyedBy: CodingKeys.self) 89 id = try container.decode(Int.self, forKey: .id) 90 name = try container.decode(String.self, forKey: .name) 91 nameI18n = try container.decodeIfPresent([String: String].self, forKey: .nameI18n) 92 selected = false 93 } 94 95 var localizedName: String { 96 guard let i18n = nameI18n else { return name } 97 let lang = Locale.current.language.languageCode?.identifier ?? "en" 98 return i18n[lang] ?? name 99 } 100 101 static func == (lhs: Category, rhs: Category) -> Bool { 102 lhs.id == rhs.id 103 } 104 105 func hash(into hasher: inout Hasher) { 106 hasher.combine(id) 107 } 108 } 109 110 struct ConfigProduct: Codable, Identifiable, Equatable { 111 let id: String 112 let productId: String? 113 let productName: String? 114 let description: String 115 let descriptionI18n: [String: String]? 116 let price: Amount 117 let pricesAreNet: Bool 118 let location: String? 119 let image: String? 120 let taxes: [Tax]? 121 let categories: [Int] 122 var quantity: Int 123 let totalStock: Int? 124 let unitTotalStock: String? 125 let totalSold: Int? 126 let unitTotalSold: String? 127 let totalLost: Int? 128 let unitTotalLost: String? 129 var availableToSell: Bool 130 var remainingStock: Int? 131 var currencyMismatch: Bool 132 133 enum CodingKeys: String, CodingKey { 134 case productId = "product_id" 135 case productName = "product_name" 136 case description 137 case descriptionI18n = "description_i18n" 138 case price 139 case pricesAreNet = "prices_are_net" 140 case location = "delivery_location" 141 case image 142 case taxes 143 case categories 144 case quantity 145 case totalStock = "total_stock" 146 case unitTotalStock = "unit_total_stock" 147 case totalSold = "total_sold" 148 case unitTotalSold = "unit_total_sold" 149 case totalLost = "total_lost" 150 case unitTotalLost = "unit_total_lost" 151 } 152 153 init( 154 id: String = UUID().uuidString, 155 productId: String? = nil, 156 productName: String? = nil, 157 description: String, 158 descriptionI18n: [String: String]? = nil, 159 price: Amount, 160 pricesAreNet: Bool = false, 161 location: String? = nil, 162 image: String? = nil, 163 taxes: [Tax]? = nil, 164 categories: [Int], 165 quantity: Int = 0, 166 totalStock: Int? = nil, 167 unitTotalStock: String? = nil, 168 totalSold: Int? = nil, 169 unitTotalSold: String? = nil, 170 totalLost: Int? = nil, 171 unitTotalLost: String? = nil, 172 availableToSell: Bool = true, 173 remainingStock: Int? = nil, 174 currencyMismatch: Bool = false 175 ) { 176 self.id = id 177 self.productId = productId 178 self.productName = productName 179 self.description = description 180 self.descriptionI18n = descriptionI18n 181 self.price = price 182 self.pricesAreNet = pricesAreNet 183 self.location = location 184 self.image = image 185 self.taxes = taxes 186 self.categories = categories 187 self.quantity = quantity 188 self.totalStock = totalStock 189 self.unitTotalStock = unitTotalStock 190 self.totalSold = totalSold 191 self.unitTotalSold = unitTotalSold 192 self.totalLost = totalLost 193 self.unitTotalLost = unitTotalLost 194 self.availableToSell = availableToSell 195 self.remainingStock = remainingStock 196 self.currencyMismatch = currencyMismatch 197 } 198 199 init(from decoder: Decoder) throws { 200 let container = try decoder.container(keyedBy: CodingKeys.self) 201 self.id = UUID().uuidString 202 self.productId = try container.decodeIfPresent(String.self, forKey: .productId) 203 self.productName = try container.decodeIfPresent(String.self, forKey: .productName) 204 self.description = try container.decode(String.self, forKey: .description) 205 self.descriptionI18n = try container.decodeIfPresent([String: String].self, forKey: .descriptionI18n) 206 self.price = try container.decode(Amount.self, forKey: .price) 207 self.pricesAreNet = try container.decodeIfPresent(Bool.self, forKey: .pricesAreNet) ?? false 208 self.location = try container.decodeIfPresent(String.self, forKey: .location) 209 self.image = try container.decodeIfPresent(String.self, forKey: .image) 210 self.taxes = try container.decodeIfPresent([Tax].self, forKey: .taxes) 211 self.categories = try container.decodeIfPresent([Int].self, forKey: .categories) ?? [] 212 self.quantity = try container.decodeIfPresent(Int.self, forKey: .quantity) ?? 0 213 self.totalStock = try container.decodeIfPresent(Int.self, forKey: .totalStock) 214 self.unitTotalStock = try container.decodeIfPresent(String.self, forKey: .unitTotalStock) 215 self.totalSold = try container.decodeIfPresent(Int.self, forKey: .totalSold) 216 self.unitTotalSold = try container.decodeIfPresent(String.self, forKey: .unitTotalSold) 217 self.totalLost = try container.decodeIfPresent(Int.self, forKey: .totalLost) 218 self.unitTotalLost = try container.decodeIfPresent(String.self, forKey: .unitTotalLost) 219 self.availableToSell = true 220 self.remainingStock = nil 221 self.currencyMismatch = false 222 } 223 224 var totalPrice: Amount { 225 price * quantity 226 } 227 228 var displayName: String { 229 let sanitizedName = productName?.sanitizedVisibleText 230 let sanitizedDesc = localizedDescription.sanitizedVisibleText 231 return sanitizedName ?? sanitizedDesc 232 } 233 234 var displayDescription: String? { 235 guard let sanitizedName = productName?.sanitizedVisibleText, 236 sanitizedName != localizedDescription.sanitizedVisibleText else { return nil } 237 let desc = localizedDescription.sanitizedVisibleText 238 return desc.isEmpty ? nil : desc 239 } 240 241 var localizedDescription: String { 242 guard let i18n = descriptionI18n else { return description } 243 let lang = Locale.current.language.languageCode?.identifier ?? "en" 244 return i18n[lang] ?? description 245 } 246 247 var stockLimit: Int? { 248 if totalStock == -1 || unitTotalStock == "-1" { return nil } 249 let total: Int? 250 if let ts = totalStock { 251 total = ts 252 } else if let uts = unitTotalStock, let d = Double(uts) { 253 total = Int(d) 254 } else { 255 return nil 256 } 257 guard let t = total else { return nil } 258 let sold: Int 259 if let ts = totalSold { 260 sold = ts 261 } else if let uts = unitTotalSold, let d = Double(uts) { 262 sold = Int(d) 263 } else { 264 sold = 0 265 } 266 let lost: Int 267 if let tl = totalLost { 268 lost = tl 269 } else if let utl = unitTotalLost, let d = Double(utl) { 270 lost = Int(d) 271 } else { 272 lost = 0 273 } 274 return max(t - sold - lost, 0) 275 } 276 277 var stableKey: String { 278 [ 279 productId?.trimmingCharacters(in: .whitespaces) ?? "", 280 productName?.trimmingCharacters(in: .whitespaces) ?? "", 281 description.trimmingCharacters(in: .whitespaces), 282 price.currency, 283 String(price.value), 284 String(price.fraction), 285 categories.map(String.init).joined(separator: ","), 286 ].joined(separator: "|") 287 } 288 289 var decodedImage: UIImage? { 290 guard let imageData = image, !imageData.isEmpty else { return nil } 291 let stripped = imageData 292 .replacingOccurrences(of: "data:image/png;base64,", with: "") 293 .replacingOccurrences(of: "data:image/jpeg;base64,", with: "") 294 guard let data = Data(base64Encoded: stripped) else { return nil } 295 return UIImage(data: data) 296 } 297 298 func toContractProduct() -> ContractProduct { 299 ContractProduct( 300 productId: productId, 301 productName: productName ?? description, 302 description: description, 303 descriptionI18n: descriptionI18n?.isEmpty == false ? descriptionI18n : nil, 304 price: price, 305 pricesAreNet: pricesAreNet, 306 quantity: quantity, 307 image: image?.isEmpty == false ? image : nil, 308 taxes: taxes?.isEmpty == false ? taxes : nil 309 ) 310 } 311 312 static func == (lhs: ConfigProduct, rhs: ConfigProduct) -> Bool { 313 lhs.id == rhs.id 314 } 315 } 316 317 struct Tax: Codable, Hashable { 318 let name: String 319 let tax: Amount 320 } 321 322 struct ContractProduct: Codable { 323 let productId: String? 324 let productName: String? 325 let description: String 326 let descriptionI18n: [String: String]? 327 let price: Amount 328 let pricesAreNet: Bool 329 let quantity: Int 330 let image: String? 331 let taxes: [Tax]? 332 333 enum CodingKeys: String, CodingKey { 334 case productId = "product_id" 335 case productName = "product_name" 336 case description 337 case descriptionI18n = "description_i18n" 338 case price 339 case pricesAreNet = "prices_are_net" 340 case quantity 341 case image 342 case taxes 343 } 344 } 345 346 private extension String { 347 var sanitizedVisibleText: String { 348 filter { char in 349 !char.unicodeScalars.contains { scalar in 350 let cat = scalar.properties.generalCategory 351 return cat == .format || cat == .control || cat == .surrogate 352 || cat == .privateUse || cat == .unassigned 353 } 354 }.trimmingCharacters(in: .whitespaces) 355 } 356 } 357 358 extension CharacterSet { 359 // .urlPathAllowed permits '/', which would split a single segment into multiple path components. 360 static let urlPathComponentAllowed: CharacterSet = { 361 var set = CharacterSet.urlPathAllowed 362 set.remove("/") 363 return set 364 }() 365 }