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 }