taler-ios

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

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 }