taler-ios

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

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 }