PaymentManager.swift (7218B)
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 Combine 19 20 @MainActor 21 class PaymentManager: ObservableObject { 22 @Published var payment: Payment? 23 @Published var deleteNeedsForce: Bool? 24 25 private let api: MerchantApi 26 private let configManager: ConfigManager 27 private var pollingTask: Task<Void, Never>? 28 29 init(api: MerchantApi, configManager: ConfigManager) { 30 self.api = api 31 self.configManager = configManager 32 } 33 34 func createPayment(order: Order, includeProducts: Bool = true, origin: PaymentOrigin = .inventory) { 35 guard let merchantConfig = configManager.merchantConfig else { return } 36 37 payment = Payment(order: order, summary: order.summary, currency: order.currency, origin: origin) 38 deleteNeedsForce = nil 39 40 let inventoryProducts: [MinimalInventoryProduct] 41 if includeProducts { 42 inventoryProducts = order.products.compactMap { product -> MinimalInventoryProduct? in 43 guard let pid = product.productId else { return nil } 44 return MinimalInventoryProduct(productId: pid, quantity: product.quantity) 45 } 46 } else { 47 inventoryProducts = [] 48 } 49 let useInventoryProducts = includeProducts && !inventoryProducts.isEmpty 50 51 let request = PostOrderRequest( 52 order: order.toContractTerms(includeProducts: includeProducts && !useInventoryProducts), 53 refundDelay: .hours(1), 54 inventoryProducts: useInventoryProducts ? inventoryProducts : nil, 55 createToken: true 56 ) 57 58 Task { 59 do { 60 let response = try await api.postOrder(merchantConfig: merchantConfig, request: request) 61 self.payment?.orderId = response.orderId 62 self.startPolling(orderId: response.orderId) 63 } catch { 64 if let apiError = error as? ApiError, apiError.isInventoryError { 65 configManager.refreshInventory() 66 } 67 self.payment?.error = error.localizedDescription 68 } 69 } 70 } 71 72 func resumePayment(item: OrderHistoryEntry) { 73 if let current = payment, 74 current.orderId == item.orderId, 75 !current.paid, 76 current.error == nil { 77 startPolling(orderId: item.orderId) 78 return 79 } 80 81 let product = ConfigProduct( 82 description: item.summary, 83 price: item.amount, 84 categories: [Int.min], 85 quantity: 1 86 ) 87 let order = Order( 88 id: -2, 89 currency: item.amount.currency, 90 availableCategories: [:], 91 products: [product] 92 ) 93 payment = Payment( 94 order: order, 95 summary: item.summary, 96 currency: item.amount.currency, 97 origin: .inventory, 98 orderId: item.orderId, 99 paid: item.paid 100 ) 101 if !item.paid { 102 startPolling(orderId: item.orderId) 103 } 104 } 105 106 func cancelPayment(error: String? = nil) { 107 stopPolling() 108 guard let merchantConfig = configManager.merchantConfig else { 109 payment?.error = error 110 return 111 } 112 if let payment = payment, !payment.paid, let orderId = payment.orderId { 113 Task { 114 try? await api.deleteOrder(merchantConfig: merchantConfig, orderId: orderId) 115 } 116 } 117 payment?.error = error 118 } 119 120 func tryDeleteOrder() { 121 stopPolling() 122 guard let merchantConfig = configManager.merchantConfig, 123 let orderId = payment?.orderId else { return } 124 Task { 125 do { 126 try await api.deleteOrder(merchantConfig: merchantConfig, orderId: orderId) 127 self.payment?.error = nil 128 self.deleteNeedsForce = false 129 } catch { 130 self.deleteNeedsForce = true 131 } 132 } 133 } 134 135 func forceDeleteOrder() { 136 guard let merchantConfig = configManager.merchantConfig, 137 let orderId = payment?.orderId else { return } 138 Task { 139 do { 140 try await api.deleteOrder(merchantConfig: merchantConfig, orderId: orderId, force: true) 141 self.payment?.error = nil 142 self.deleteNeedsForce = nil 143 } catch { 144 self.payment?.error = error.localizedDescription 145 } 146 } 147 } 148 149 func clearDeleteNeedsForce() { 150 deleteNeedsForce = nil 151 } 152 153 func reset() { 154 stopPolling() 155 payment = nil 156 deleteNeedsForce = nil 157 } 158 159 private func startPolling(orderId: String) { 160 stopPolling() 161 pollingTask = Task { 162 guard let merchantConfig = configManager.merchantConfig else { return } 163 var transientFailures = 0 164 let maxTransientRetries = 10 165 while !Task.isCancelled { 166 do { 167 let response = try await api.checkOrder(merchantConfig: merchantConfig, orderId: orderId) 168 transientFailures = 0 169 switch response { 170 case .unpaid(let uri): 171 self.payment?.talerPayUri = uri 172 case .claimed: 173 self.payment?.claimed = true 174 case .paid(_, _, _): 175 self.payment?.paid = true 176 self.stopPolling() 177 } 178 if case .paid = response { break } 179 } catch let apiError as ApiError where apiError.isUnauthorized { 180 self.configManager.forgetPassword() 181 self.configManager.sessionExpired = true 182 self.payment?.error = apiError.localizedDescription 183 break 184 } catch let apiError as ApiError where apiError.isPermanentClientError { 185 self.payment?.error = apiError.localizedDescription 186 break 187 } catch { 188 transientFailures += 1 189 if transientFailures >= maxTransientRetries { 190 self.payment?.error = error.localizedDescription 191 break 192 } 193 } 194 try? await Task.sleep(nanoseconds: 1_000_000_000) 195 } 196 } 197 } 198 199 private func stopPolling() { 200 pollingTask?.cancel() 201 pollingTask = nil 202 } 203 }