taler-ios

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

MerchantApi.swift (18605B)


      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 os
     19 
     20 private let logger = Logger(subsystem: "net.taler.pos", category: "MerchantApi")
     21 
     22 enum ApiError: Error, LocalizedError {
     23     case network(String)
     24     case unauthorized
     25     case http(Int, String?, Int?)
     26     case decodingFailed(String)
     27     case invalidURL
     28 
     29     var errorDescription: String? {
     30         switch self {
     31         case .network(let msg): return msg
     32         case .unauthorized: return "Unauthorized (401): check your access token."
     33         case .http(let code, let hint, _):
     34             if let hint { return "Error \(code): \(hint)" }
     35             return "HTTP error (\(code))"
     36         case .decodingFailed(let msg): return "Failed to parse response: \(msg)"
     37         case .invalidURL: return "Invalid URL"
     38         }
     39     }
     40 
     41     var isUnauthorized: Bool {
     42         switch self {
     43         case .unauthorized: return true
     44         case .http(401, _, _): return true
     45         default: return false
     46         }
     47     }
     48 
     49     var isInventoryError: Bool {
     50         guard case .http(_, let hint, _) = self, let hint else { return false }
     51         let lower = hint.lowercased()
     52         return lower.contains("inventory") || lower.contains("stock") ||
     53                lower.contains("insufficient") || lower.contains("sold out") ||
     54                lower.contains("out of stock")
     55     }
     56 
     57     var backendCode: Int? {
     58         switch self {
     59         case .http(_, _, let code): return code
     60         default: return nil
     61         }
     62     }
     63 
     64     var isPermanentClientError: Bool {
     65         switch self {
     66         case .http(let status, _, _): return (400..<500).contains(status) && status != 401 && status != 408 && status != 429
     67         default: return false
     68         }
     69     }
     70 }
     71 
     72 struct ApiErrorResponse: Codable {
     73     let code: Int?
     74     let hint: String?
     75     let detail: String?
     76 }
     77 
     78 struct PostOrderRequest: Codable {
     79     let order: ContractTerms
     80     let refundDelay: RelativeTime?
     81     let inventoryProducts: [MinimalInventoryProduct]?
     82     let createToken: Bool
     83 
     84     enum CodingKeys: String, CodingKey {
     85         case order
     86         case refundDelay = "refund_delay"
     87         case inventoryProducts = "inventory_products"
     88         case createToken = "create_token"
     89     }
     90 }
     91 
     92 struct RelativeTime: Codable {
     93     let dUs: UInt64
     94 
     95     enum CodingKeys: String, CodingKey {
     96         case dUs = "d_us"
     97     }
     98 
     99     static func hours(_ h: Int) -> RelativeTime {
    100         RelativeTime(dUs: UInt64(h) * 3_600_000_000)
    101     }
    102 }
    103 
    104 struct MinimalInventoryProduct: Codable {
    105     let productId: String
    106     let quantity: Int?
    107 
    108     enum CodingKeys: String, CodingKey {
    109         case productId = "product_id"
    110         case quantity
    111     }
    112 }
    113 
    114 struct PostOrderResponse: Codable {
    115     let orderId: String
    116 
    117     enum CodingKeys: String, CodingKey {
    118         case orderId = "order_id"
    119     }
    120 }
    121 
    122 enum CheckPaymentResponse {
    123     case unpaid(talerPayUri: String)
    124     case claimed
    125     case paid(refunded: Bool, refundPending: Bool, refundAmount: Amount?)
    126 }
    127 
    128 extension CheckPaymentResponse: Decodable {
    129     enum CodingKeys: String, CodingKey {
    130         case orderStatus = "order_status"
    131         case talerPayUri = "taler_pay_uri"
    132         case paid
    133         case refunded
    134         case refundPending = "refund_pending"
    135         case refundAmount = "refund_amount"
    136     }
    137 
    138     init(from decoder: Decoder) throws {
    139         let container = try decoder.container(keyedBy: CodingKeys.self)
    140         let status = try container.decode(String.self, forKey: .orderStatus)
    141         switch status {
    142         case "unpaid":
    143             let uri = try container.decode(String.self, forKey: .talerPayUri)
    144             self = .unpaid(talerPayUri: uri)
    145         case "claimed":
    146             self = .claimed
    147         case "paid":
    148             let refunded = try container.decode(Bool.self, forKey: .refunded)
    149             let refundPending = try container.decodeIfPresent(Bool.self, forKey: .refundPending) ?? false
    150             let refundAmount = try container.decodeIfPresent(Amount.self, forKey: .refundAmount)
    151             self = .paid(refunded: refunded, refundPending: refundPending, refundAmount: refundAmount)
    152         default:
    153             throw DecodingError.dataCorruptedError(forKey: .orderStatus, in: container, debugDescription: "Unknown order_status: \(status)")
    154         }
    155     }
    156 }
    157 
    158 struct RefundRequest: Codable {
    159     let refund: Amount
    160     let reason: String
    161 }
    162 
    163 struct RefundResponse: Codable {
    164     let talerRefundUri: String
    165 
    166     enum CodingKeys: String, CodingKey {
    167         case talerRefundUri = "taler_refund_uri"
    168     }
    169 }
    170 
    171 // MARK: - MFA / Token models
    172 
    173 enum TanChannel: String, Codable {
    174     case sms
    175     case email
    176 }
    177 
    178 struct MfaChallenge: Codable, Identifiable {
    179     let challengeId: String
    180     let tanChannel: TanChannel
    181     let tanInfo: String
    182 
    183     var id: String { challengeId }
    184 
    185     enum CodingKeys: String, CodingKey {
    186         case challengeId = "challenge_id"
    187         case tanChannel = "tan_channel"
    188         case tanInfo = "tan_info"
    189     }
    190 }
    191 
    192 struct ChallengesResponse: Codable {
    193     let challenges: [MfaChallenge]
    194     let combiAnd: Bool
    195 
    196     enum CodingKeys: String, CodingKey {
    197         case challenges
    198         case combiAnd = "combi_and"
    199     }
    200 }
    201 
    202 struct TokenRequest: Codable {
    203     let scope: String
    204     let duration: TokenDuration
    205 }
    206 
    207 struct TokenDuration: Codable {
    208     let dUs: String // "forever" or microseconds as string
    209 
    210     static let forever = TokenDuration(dUs: "forever")
    211 
    212     enum CodingKeys: String, CodingKey {
    213         case dUs = "d_us"
    214     }
    215 
    216     func encode(to encoder: Encoder) throws {
    217         var container = encoder.container(keyedBy: CodingKeys.self)
    218         if dUs == "forever" {
    219             try container.encode("forever", forKey: .dUs)
    220         } else if let num = Int64(dUs) {
    221             try container.encode(num, forKey: .dUs)
    222         } else {
    223             try container.encode(dUs, forKey: .dUs)
    224         }
    225     }
    226 }
    227 
    228 struct LimitedTokenResponse: Codable {
    229     let token: String
    230 }
    231 
    232 struct ChallengeConfirmRequest: Codable {
    233     let tan: String
    234 }
    235 
    236 struct ChallengeRequiredError: Error {
    237     let challengeResponse: ChallengesResponse
    238 }
    239 
    240 struct MerchantBackendConfigResponse: Codable {
    241     let version: String
    242     let currency: String
    243     let currencies: [String: CurrencySpecification]?
    244 }
    245 
    246 struct ProductDetail: Codable {
    247     let totalSold: Int?
    248     let totalLost: Int?
    249     let unitTotalSold: String?
    250     let unitTotalLost: String?
    251 
    252     enum CodingKeys: String, CodingKey {
    253         case totalSold = "total_sold"
    254         case totalLost = "total_lost"
    255         case unitTotalSold = "unit_total_sold"
    256         case unitTotalLost = "unit_total_lost"
    257     }
    258 }
    259 
    260 class MerchantApi {
    261     private let session: URLSession
    262     private let decoder: JSONDecoder
    263     private let encoder: JSONEncoder
    264     var onUnauthorized: (() -> Void)?
    265 
    266     init(session: URLSession = .shared) {
    267         self.session = session
    268         self.decoder = JSONDecoder()
    269         self.encoder = JSONEncoder()
    270     }
    271 
    272     func fetchPosConfig(merchantUrl: String, accessToken: String) async throws -> PosConfig {
    273         var url = merchantUrl
    274         if !url.hasSuffix("/") { url += "/" }
    275         url += "private/pos"
    276         return try await get(url: url, accessToken: accessToken)
    277     }
    278 
    279     func fetchBackendConfig(merchantConfig: MerchantConfig) async throws -> MerchantBackendConfigResponse {
    280         return try await get(url: merchantConfig.urlFor("config"), accessToken: merchantConfig.apiKey)
    281     }
    282 
    283     func fetchProductDetail(merchantConfig: MerchantConfig, productId: String) async throws -> ProductDetail {
    284         let url = merchantConfig.urlForPathComponent("private/products", segment: productId)
    285         return try await get(url: url, accessToken: merchantConfig.apiKey)
    286     }
    287 
    288     func postOrder(merchantConfig: MerchantConfig, request: PostOrderRequest) async throws -> PostOrderResponse {
    289         let url = merchantConfig.urlFor("private/orders")
    290         return try await post(url: url, body: request, accessToken: merchantConfig.apiKey)
    291     }
    292 
    293     func checkOrder(merchantConfig: MerchantConfig, orderId: String) async throws -> CheckPaymentResponse {
    294         let url = merchantConfig.urlForPathComponent("private/orders", segment: orderId)
    295         return try await get(url: url, accessToken: merchantConfig.apiKey)
    296     }
    297 
    298     func deleteOrder(merchantConfig: MerchantConfig, orderId: String, force: Bool = false) async throws {
    299         var urlStr = merchantConfig.urlForPathComponent("private/orders", segment: orderId)
    300         if force { urlStr += "?force=yes" }
    301         guard let url = URL(string: urlStr) else { throw ApiError.invalidURL }
    302         var request = URLRequest(url: url)
    303         request.httpMethod = "DELETE"
    304         request.setValue("Bearer \(merchantConfig.apiKey)", forHTTPHeaderField: "Authorization")
    305         let (data, response) = try await session.data(for: request)
    306         try checkResponse(response, data: data)
    307     }
    308 
    309     func getOrderHistory(merchantConfig: MerchantConfig, limit: Int = -20, offset: Int64? = nil) async throws -> OrderHistory {
    310         var urlStr = merchantConfig.urlFor("private/orders") + "?limit=\(limit)"
    311         if let offset { urlStr += "&offset=\(offset)" }
    312         return try await get(url: urlStr, accessToken: merchantConfig.apiKey)
    313     }
    314 
    315     func giveRefund(merchantConfig: MerchantConfig, orderId: String, request: RefundRequest) async throws -> RefundResponse {
    316         let url = merchantConfig.urlForPathComponent("private/orders", segment: orderId) + "/refund"
    317         return try await post(url: url, body: request, accessToken: merchantConfig.apiKey)
    318     }
    319 
    320     // MARK: - MFA Token Exchange
    321 
    322     func fetchLimitedAccessToken(
    323         baseUrl: String,
    324         username: String,
    325         initialSecret: String,
    326         duration: TokenDuration = .forever,
    327         challengeIds: [String] = []
    328     ) async throws -> String {
    329         let encodedUser = username.addingPercentEncoding(withAllowedCharacters: .urlPathComponentAllowed) ?? username
    330         let urlStr = "\(baseUrl)/instances/\(encodedUser)/private/token"
    331         guard let url = URL(string: urlStr) else { throw ApiError.invalidURL }
    332 
    333         let tokenRequest = TokenRequest(scope: "write", duration: duration)
    334         var request = URLRequest(url: url)
    335         request.httpMethod = "POST"
    336         request.setValue("Bearer secret-token:\(initialSecret)", forHTTPHeaderField: "Authorization")
    337         request.setValue("application/json", forHTTPHeaderField: "Content-Type")
    338         if !challengeIds.isEmpty {
    339             request.setValue(challengeIds.joined(separator: ","), forHTTPHeaderField: "Taler-Challenge-Ids")
    340         }
    341         request.httpBody = try encoder.encode(tokenRequest)
    342 
    343         let (data, response): (Data, URLResponse)
    344         do {
    345             (data, response) = try await session.data(for: request)
    346         } catch {
    347             throw ApiError.network(error.localizedDescription)
    348         }
    349 
    350         guard let httpResponse = response as? HTTPURLResponse else {
    351             throw ApiError.network("Invalid response")
    352         }
    353 
    354         if httpResponse.statusCode == 202 {
    355             let challengeResponse = try decoder.decode(ChallengesResponse.self, from: data)
    356             throw ChallengeRequiredError(challengeResponse: challengeResponse)
    357         }
    358 
    359         if httpResponse.statusCode == 401 {
    360             throw ApiError.unauthorized
    361         }
    362 
    363         guard (200..<300).contains(httpResponse.statusCode) else {
    364             var hint: String?
    365             var backendCode: Int?
    366             if let errorResp = try? decoder.decode(ApiErrorResponse.self, from: data) {
    367                 hint = errorResp.hint ?? errorResp.detail
    368                 backendCode = errorResp.code
    369             }
    370             throw ApiError.http(httpResponse.statusCode, hint, backendCode)
    371         }
    372 
    373         let tokenResponse = try decoder.decode(LimitedTokenResponse.self, from: data)
    374         return tokenResponse.token
    375     }
    376 
    377     /// POST /instances/{username}/challenge/{challengeId}
    378     /// Requests a challenge (sends TAN to user's phone/email)
    379     func requestChallenge(baseUrl: String, username: String, challengeId: String) async throws {
    380         let encodedUser = username.addingPercentEncoding(withAllowedCharacters: .urlPathComponentAllowed) ?? username
    381         let encodedChallenge = challengeId.addingPercentEncoding(withAllowedCharacters: .urlPathComponentAllowed) ?? challengeId
    382         let urlStr = "\(baseUrl)/instances/\(encodedUser)/challenge/\(encodedChallenge)"
    383         guard let url = URL(string: urlStr) else { throw ApiError.invalidURL }
    384 
    385         var request = URLRequest(url: url)
    386         request.httpMethod = "POST"
    387         request.setValue("application/json", forHTTPHeaderField: "Content-Type")
    388         request.httpBody = Data("{}".utf8)
    389 
    390         let (data, response): (Data, URLResponse)
    391         do {
    392             (data, response) = try await session.data(for: request)
    393         } catch {
    394             throw ApiError.network(error.localizedDescription)
    395         }
    396         try checkResponse(response, data: data)
    397     }
    398 
    399     /// POST /instances/{username}/challenge/{challengeId}/confirm
    400     /// Confirms a challenge with the TAN code
    401     /// HTTP 409 = invalid code, HTTP 429 = rate limited
    402     func confirmChallenge(baseUrl: String, username: String, challengeId: String, tan: String) async throws {
    403         let encodedUser = username.addingPercentEncoding(withAllowedCharacters: .urlPathComponentAllowed) ?? username
    404         let encodedChallenge = challengeId.addingPercentEncoding(withAllowedCharacters: .urlPathComponentAllowed) ?? challengeId
    405         let urlStr = "\(baseUrl)/instances/\(encodedUser)/challenge/\(encodedChallenge)/confirm"
    406         guard let url = URL(string: urlStr) else { throw ApiError.invalidURL }
    407 
    408         let confirmRequest = ChallengeConfirmRequest(tan: tan)
    409         var request = URLRequest(url: url)
    410         request.httpMethod = "POST"
    411         request.setValue("application/json", forHTTPHeaderField: "Content-Type")
    412         request.httpBody = try encoder.encode(confirmRequest)
    413 
    414         let (data, response): (Data, URLResponse)
    415         do {
    416             (data, response) = try await session.data(for: request)
    417         } catch {
    418             throw ApiError.network(error.localizedDescription)
    419         }
    420 
    421         guard let httpResponse = response as? HTTPURLResponse else {
    422             throw ApiError.network("Invalid response")
    423         }
    424 
    425         switch httpResponse.statusCode {
    426         case 200..<300:
    427             return // success
    428         case 409:
    429             throw ApiError.http(409, "Invalid verification code", nil)
    430         case 429:
    431             throw ApiError.http(429, "Too many attempts. Please request a new code.", nil)
    432         default:
    433             try checkResponse(response, data: data)
    434         }
    435     }
    436 
    437     // MARK: - Private
    438 
    439     private func get<T: Decodable>(url: String, accessToken: String) async throws -> T {
    440         guard let url = URL(string: url) else { throw ApiError.invalidURL }
    441         var request = URLRequest(url: url)
    442         request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
    443         logger.warning("GET \(url.absoluteString)")
    444         let (data, response): (Data, URLResponse)
    445         do {
    446             (data, response) = try await session.data(for: request)
    447         } catch {
    448             logger.error("GET \(url.absoluteString) network error: \(error.localizedDescription)")
    449             throw ApiError.network(error.localizedDescription)
    450         }
    451         if let http = response as? HTTPURLResponse {
    452             logger.warning("GET \(url.absoluteString) -> \(http.statusCode)")
    453         }
    454         try checkResponse(response, data: data)
    455         do {
    456             return try decoder.decode(T.self, from: data)
    457         } catch {
    458             logger.error("GET \(url.absoluteString) decode \(String(describing: T.self)) failed: \(error)")
    459             throw ApiError.decodingFailed(error.localizedDescription)
    460         }
    461     }
    462 
    463     private func post<T: Decodable, B: Encodable>(url: String, body: B, accessToken: String) async throws -> T {
    464         guard let url = URL(string: url) else { throw ApiError.invalidURL }
    465         var request = URLRequest(url: url)
    466         request.httpMethod = "POST"
    467         request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
    468         request.setValue("application/json", forHTTPHeaderField: "Content-Type")
    469         request.httpBody = try encoder.encode(body)
    470         logger.warning("POST \(url.absoluteString)")
    471         let (data, response): (Data, URLResponse)
    472         do {
    473             (data, response) = try await session.data(for: request)
    474         } catch {
    475             logger.error("POST \(url.absoluteString) network error: \(error.localizedDescription)")
    476             throw ApiError.network(error.localizedDescription)
    477         }
    478         if let http = response as? HTTPURLResponse {
    479             logger.warning("POST \(url.absoluteString) -> \(http.statusCode)")
    480         }
    481         try checkResponse(response, data: data)
    482         do {
    483             return try decoder.decode(T.self, from: data)
    484         } catch {
    485             logger.error("POST \(url.absoluteString) decode \(String(describing: T.self)) failed: \(error)")
    486             throw ApiError.decodingFailed(error.localizedDescription)
    487         }
    488     }
    489 
    490     private func checkResponse(_ response: URLResponse, data: Data? = nil) throws {
    491         guard let httpResponse = response as? HTTPURLResponse else { return }
    492         switch httpResponse.statusCode {
    493         case 200..<300: return
    494         case 401:
    495             onUnauthorized?()
    496             throw ApiError.unauthorized
    497         default:
    498             var hint: String?
    499             var backendCode: Int?
    500             if let data {
    501                 if let errorResp = try? JSONDecoder().decode(ApiErrorResponse.self, from: data) {
    502                     hint = errorResp.hint ?? errorResp.detail
    503                     backendCode = errorResp.code
    504                 }
    505             }
    506             throw ApiError.http(httpResponse.statusCode, hint, backendCode)
    507         }
    508     }
    509 }