taler-ios

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

Transaction.swift (40004B)


      1 /*
      2  * This file is part of GNU Taler, ©2022-25 Taler Systems S.A.
      3  * See LICENSE.md
      4  */
      5 /**
      6  * @author Marc Stibane
      7  */
      8 import Foundation
      9 import AnyCodable
     10 import taler_swift
     11 import SymLog
     12 import SwiftUI
     13 
     14 enum TransactionTypeError: Error {
     15     case unknownTypeError
     16 }
     17 
     18 enum TransactionDecodingError: Error {
     19     case invalidStringValue
     20 }
     21 
     22 enum TransactionMinorState: String, Codable {
     23     case abort                                                  // failed - user abandoned it
     24     case abortingBank = "aborting-bank"                         // failed
     25     case acceptRefund = "accept-refund"
     26     case autoRefund = "auto-refund"                             // TODO: finalizing
     27     case balanceKyc = "balance-kyc"                             // pending - exceeds balance limit
     28     case bank                                                   // aborting in withdrawal
     29     case bankConfirmTransfer = "bank-confirm-transfer"
     30     case bankRegisterReserve = "bank-register-reserve"
     31     case checkRefund = "check-refund"
     32     case claimProposal = "claim-proposal"
     33     case completedByOtherWallet = "completed-by-other-wallet"   // aborted
     34     case createPurse = "create-purse"
     35     case deletePurse = "delete-purse"                           // aborting
     36     case deposit                                                // exchange wire-transfers to bank
     37     case exchange                                               // aborted
     38     case exchangeWaitReserve = "exchange-wait-reserve"
     39     case kyc                // KycRequired
     40     case kycAuthRequired = "kyc-auth"
     41     case kycInit = "kyc-init"
     42     case merge                                                  // peerPushCredit
     43     case paidByOther = "paid-by-other"                          // failed webEx -> mobile
     44     case proposed                                               // dialog
     45     case ready                                                  // P2P
     46     case rebindSession = "rebind-session"
     47     case refresh                                                // aborting
     48     case refused                                                // aborted
     49     case repurchase                                             // failed
     50     case submitPayment = "submit-payment"
     51     case track                                                  // finalizing deposit - late KYC / failure
     52     case withdraw
     53     // Placeholder until D37 is fully implemented
     54     case unknown
     55 
     56     /// wallet-core keeps adding minor states (`abort` was added after this enum was
     57     /// written). A raw-value enum throws on an unknown value even when the property is
     58     /// Optional, and because the transaction list is decoded as one array that throw
     59     /// discards *every* transaction. Degrade to `.unknown` instead.
     60     init(from decoder: Decoder) throws {
     61         let raw = try decoder.singleValueContainer().decode(String.self)
     62         self = TransactionMinorState(rawValue: raw) ?? .unknown
     63     }
     64 
     65     var localizedDbgState: String { self.rawValue }
     66     var localizedState: String? {
     67         switch self {
     68             case .kycInit:            return String(localized: "MinorState.kycInit", defaultValue: "Preparing legitimization", comment: "TxMinorState heading")
     69             case .kycAuthRequired:    return String(localized: "MinorState.kycAuth", defaultValue: "Verify bank account", comment: "TxMinorState heading")
     70             case .balanceKyc,
     71                  .kyc:                return String(localized: "MinorState.kyc", defaultValue: "Legitimization required", comment: "TxMinorState heading")
     72             case .acceptRefund,
     73                  .checkRefund:        return String(localized: "MinorState.acceptRefund", defaultValue: "Checking for refund", comment: "TxMinorState heading")
     74             case .bankConfirmTransfer:return String(localized: "MinorState.bankConfirmTransfer", defaultValue: "Waiting for bank transfer", comment: "TxMinorState heading")
     75             case .completedByOtherWallet:return String(localized: "MinorState.completedByOther", defaultValue: "Completed by other wallet", comment: "TxMinorState heading")
     76 //            case .exchange:                 return self.rawValue  // TODO: mention that money will come back
     77             case .proposed:           return self.rawValue   // TODO: discuss
     78             case .ready:              return String(localized: "MinorState.ready", defaultValue: "Ready", comment: "TxMinorState heading")
     79             case .rebindSession:        return String(localized: "MinorState.rebindSession", defaultValue: "Restoring access", comment: "TxMinorState heading")
     80 //            case .track:                    return self.rawValue
     81             default: return nil
     82         }
     83     }
     84 }
     85 
     86 enum TransactionMajorState: String, Codable {
     87       // No state, only used when reporting transitions into the initial state
     88     case none
     89     case pending
     90     case done
     91     case aborting
     92     case aborted
     93     case dialog
     94     // Florian: Should IMO be rendered like a done state, but with the possibility of suspend/resume buttons. In the minor state auto-refund, we could display some additional hint "The wallet is automatically checking for refunds until XYZ" but very low priority to show this IMO.
     95     case finalizing
     96     case suspended
     97     case suspendedFinalizing = "suspended-finalizing"
     98     case suspendedAborting = "suspended-aborting"
     99     case failed
    100     case expired
    101       // Only used for the notification, never in the transaction history
    102     case deleted
    103 
    104     var localizedState: String {
    105         switch self {
    106             case .none:      return                   "none"
    107             case .pending:   return String(localized: "MajorState.Pending", defaultValue: "Pending", comment: "TxMajorState heading")
    108             case .finalizing:return String(localized: "MajorState.Finalizing", defaultValue: "Finalizing", comment: "TxMajorState heading")
    109             case .done:      return String(localized: "MajorState.Done", defaultValue: "Completed successfully", comment: "TxMajorState heading")
    110             case .aborting:  return String(localized: "MajorState.Aborting", defaultValue: "Aborting", comment: "TxMajorState heading")
    111             case .aborted:   return String(localized: "MajorState.Aborted", defaultValue: "Aborted", comment: "TxMajorState heading")
    112             case .suspended: return                   "Suspended"
    113             case .dialog:    return String(localized: "MajorState.Dialog", defaultValue: "Dialog", comment: "TxMajorState heading")
    114             case .suspendedAborting: return           "AbortingSuspended"
    115             case .suspendedFinalizing: return         "FinalizingSuspended"
    116             case .failed:    return String(localized: "MajorState.Failed", defaultValue: "Abandoned", comment: "TxMajorState heading")
    117             case .expired:   return String(localized: "MajorState.Expired", defaultValue: "Expired", comment: "TxMajorState heading")
    118             case .deleted:   return String(localized: "MajorState.Deleted", defaultValue: "Deleted", comment: "TxMajorState heading")
    119         }
    120     }
    121 }
    122 
    123 struct PayTo {          // receiver-name=Taler+Operations+AG&receiver-postal-code=2502&receiver-town=Biel-Bienne"
    124     var iban: String?
    125     var cyclos: String?
    126     var xTaler: String?
    127     var sender: String?
    128     var receiver: String?
    129     var postalCode: String?
    130     var town: String?
    131     var amountStr: String?
    132     var messageStr: String?
    133     var chQRr: String?
    134     //   payto-cyclos-URI = "payto://cyclos/" host ["/" fpath] "/" account-id [ "?" opts ]
    135     var host: String?       //
    136 
    137     func param(key: String, from params: [String:String]) -> String? {
    138         if let param = params[key] {
    139             return param.replacingOccurrences(of: "+", with: SPACE)
    140         }
    141         return nil
    142     }
    143 
    144     init(_ string: String) {
    145         let payURL = URL(string: string)
    146         if let host = payURL?.host {
    147             self.host = host
    148         }
    149         // the payment method is encoded in the path
    150         iban = payURL?.iban
    151         cyclos = payURL?.cyclos
    152         xTaler = payURL?.xTaler ??
    153 //               payURL?.host() ??
    154                  String(localized: "unknown payment method")
    155         if let queryParameters = payURL?.queryParameters {
    156             sender = param(key: "sender-name", from: queryParameters)
    157             receiver = param(key: "receiver-name", from: queryParameters)
    158             postalCode = param(key: "receiver-postal-code", from: queryParameters)
    159             town = param(key: "receiver-town", from: queryParameters)
    160             amountStr = queryParameters["amount"]
    161             chQRr = queryParameters["ch-qrr"]
    162             messageStr = queryParameters["message"]
    163         }
    164     }
    165 }
    166 
    167 struct TransactionState: Codable, Hashable {
    168     var major: TransactionMajorState
    169     var minor: TransactionMinorState?
    170     var working: Bool?
    171 
    172     var isWorking: Bool { working ?? false }
    173     var isConfirmed: Bool { major == .done
    174                          || major == .pending
    175                          || major == .finalizing }
    176     var isReady: Bool { minor == .ready }
    177     var isKYC: Bool { minor == .kyc
    178 //                   || minor == .kycInit
    179                    || minor == .balanceKyc }
    180     var isKYCauth: Bool { minor == .kycAuthRequired }
    181 }
    182 
    183 enum ProgressPhase: String, Codable {
    184     case delayed        // after 5 seconds
    185     case stalled        // after 10 seconds
    186     case done           // either successful or error
    187 }
    188 struct RequestProgressPhase: Codable {             // Notification
    189     var type: String        // request-progress-phase
    190     var operation: String
    191     var progressToken: String
    192     var phase: ProgressPhase
    193 }
    194 
    195 struct RequestProgressError: Codable {             // Notification
    196     var type: String        // request-progress-error
    197     var operation: String
    198     var progressToken: String
    199     var error: TalerErrorDetail
    200     var retryCounter: Int?
    201     var nextRetryDelay: Duration?
    202 }
    203 
    204 struct TransactionTransition: Codable {             // Notification
    205     var type: String        // transaction-state-transition
    206     var oldTxState: TransactionState?
    207     var newTxState: TransactionState
    208     var transactionId: String
    209     var experimentalUserData: String?       // KYC
    210     var errorInfo:  TalerErrorDetail?       // shortened error
    211 }
    212 
    213 enum TxAction: String, Codable {
    214     case delete     // dialog,done,expired,aborted,failed -> ()
    215     case suspend    // pending -> suspended; aborting -> ab_suspended
    216     case resume     // suspended -> pending; ab_suspended -> aborting
    217     case abort      // pending,dialog,suspended -> aborting
    218 //  case revive     // aborting -> pending ?? maybe post 1.0
    219     case fail       // aborting -> failed
    220     case retry      //
    221 
    222     var localizedActionTitle: String {
    223         return switch self {
    224             case .delete:   String(localized: "TxAction.Delete", defaultValue: "Delete from history", comment: "TxAction button")
    225             case .suspend:  String("Suspend")
    226             case .resume:   String("Resume")
    227             case .abort:    String(localized: "TxAction.Abort", defaultValue: "Abort", comment: "TxAction button")
    228 //            case .revive:   String(localized: "TxAction.Revive", defaultValue: "Revive", comment: "TxAction button")
    229             case .fail:     String(localized: "TxAction.Fail", defaultValue: "Abandon", comment: "TxAction button")
    230             case .retry:    String(localized: "TxAction.Retry", defaultValue: "Retry now", comment: "TxAction button")
    231         }
    232     }
    233     var localizedActionImage: String? {
    234         return switch self {
    235             case .delete:   "trash"                     // 􀈑
    236             case .suspend:
    237                 if #available(iOS 16.4, *) {
    238                     "clock.badge.xmark"                 // 􁜒
    239                 } else {
    240                     "clock.badge.exclamationmark"       // 􀹶
    241                 }
    242             case .resume:   "clock.arrow.circlepath"    // 􀣔
    243             case .abort:    "x.circle"                  // 􀀲
    244 //            case .revive:   "clock.arrow.circlepath"    // 􀣔
    245             case .fail:     "play.slash"                // 􀪅
    246             case .retry:    "arrow.circlepath"          // 􁹠
    247         }
    248     }
    249     var localizedActionExecuted: String {
    250         switch self {
    251             case .delete:   return String(localized: "TxActionDone.Delete", defaultValue: "Deleted from list", comment: "TxAction button")
    252             case .suspend:  return String("Suspending...")
    253             case .resume:   return String("Resuming...")
    254             case .abort:    return String(localized: "TxActionDone.Abort", defaultValue: "Abort pending...", comment: "TxAction button")
    255 //            case .revive:   return String(localized: "TxActionDone.Revive", defaultValue: "Revive", comment: "TxAction button")
    256             case .fail:     return String(localized: "TxActionDone.Fail", defaultValue: "Abandoning...", comment: "TxAction button")
    257             case .retry:    return String(localized: "TxActionDone.Retry", defaultValue: "Retrying...", comment: "TxAction button")
    258         }
    259     }
    260 }
    261 
    262 enum TransactionType: String, Codable {
    263     case dummy
    264     case withdrawal
    265     case deposit
    266     case payment
    267     case refund
    268     case refresh
    269 //    case tip                                  // tip personnel at restaurants
    270     case peerPushDebit  = "peer-push-debit"     // send coins to peer, show QR
    271     case scanPushCredit = "peer-push-credit"    // scan QR, receive coins from peer
    272     case peerPullCredit = "peer-pull-credit"    // request payment from peer, show QR
    273     case scanPullDebit  = "peer-pull-debit"     // scan QR, pay requested
    274 //    case internalWithdrawal = "internal-withdrawal"
    275     case recoup                                 // denomination revoked
    276     case denomLoss      = "denom-loss"          // coins are lost, denomination no longer available
    277 
    278     var isWithdrawal : Bool { self == .withdrawal }
    279     var isDeposit    : Bool { self == .deposit }
    280     var isPayment    : Bool { self == .payment }
    281     var isRefund     : Bool { self == .refund }
    282     var isRefresh    : Bool { self == .refresh }
    283     var isSendCoins  : Bool { self == .peerPushDebit }
    284     var isRcvCoins   : Bool { self == .scanPushCredit }
    285     var isSendInvoice: Bool { self == .peerPullCredit }
    286     var isPayInvoice : Bool { self == .scanPullDebit }
    287 
    288     var isP2pOutgoing: Bool { isSendCoins || isPayInvoice}
    289     var isP2pIncoming: Bool { isSendInvoice || isRcvCoins}
    290     var isIncoming   : Bool { isP2pIncoming || isWithdrawal || isRefund }
    291     var iconName: String {
    292         switch self {
    293             case .dummy:            ICONNAME_DUMMY
    294             case .withdrawal:       ICONNAME_WITHDRAWAL
    295             case .deposit:          ICONNAME_DEPOSIT
    296             case .payment:          ICONNAME_PAYMENT
    297             case .refund:           ICONNAME_REFUND
    298             case .refresh:          ICONNAME_REFRESH
    299             case .peerPushDebit:    ICONNAME_OUTGOING
    300             case .scanPushCredit:   ICONNAME_INCOMING
    301             case .peerPullCredit:   ICONNAME_INCOMING
    302             case .scanPullDebit:    ICONNAME_OUTGOING
    303             case .recoup:           ICONNAME_RECOUP
    304             case .denomLoss:        ICONNAME_DENOMLOSS
    305         }
    306     }
    307     var sysIconName: String {
    308         switch self {
    309             case .dummy:            SYSTEM_DUMMY1
    310             case .withdrawal:       SYSTEM_WITHDRAWAL5
    311             case .deposit:          SYSTEM_DEPOSIT5
    312             case .payment:          SYSTEM_PAYMENT2
    313             case .refund:           SYSTEM_REFUND2
    314             case .refresh:          SYSTEM_REFRESH6
    315             case .peerPushDebit:    SYSTEM_OUTGOING4
    316             case .scanPushCredit:   SYSTEM_INCOMING4
    317             case .peerPullCredit:   SYSTEM_INCOMING4
    318             case .scanPullDebit:    SYSTEM_OUTGOING4
    319             case .recoup:           SYSTEM_RECOUP1
    320             case .denomLoss:        SYSTEM_DENOMLOSS1
    321         }
    322     }
    323     func icon(_ done: Bool = false) -> Image {
    324         // try assets first
    325         let name = done ? iconName + ICONNAME_FILL : iconName
    326         if UIImage(named: name) != nil { return Image(name) }
    327 
    328         // fallback to system icons
    329         let sysName = isRefresh ? (done ? SYSTEM_REFRESH6_fill : SYSTEM_REFRESH6)
    330                                 : (done ? sysIconName + ICONNAME_FILL : sysIconName)
    331         if UIImage(systemName: sysName) != nil { return Image(systemName: sysName) }
    332 //            .symbolVariant(done ? .fill : .none)
    333 
    334         // on older iOS Versions, fallback to simpler icons
    335         if isRefresh { return Image(systemName: FALLBACK_REFRESH) }
    336         if isDeposit { return Image(systemName: FALLBACK_DEPOSIT) }
    337         if isWithdrawal { return Image(systemName: FALLBACK_WITHDRAWAL) }
    338         if isP2pOutgoing { return Image(systemName: FALLBACK_OUTGOING) }
    339         if isP2pIncoming { return Image(systemName: FALLBACK_INCOMING) }
    340 
    341         // finally, there's always dummy
    342         return Image(systemName: SYSTEM_DUMMY1)
    343     }
    344 }
    345 
    346 struct KycAuthTransferInfo: Decodable, Sendable {
    347      /// The KYC auth transfer will *not* work if it originates from a different account.
    348     var debitPaytoUri: String                           /// Payto URI of the account that must make the transfer
    349     var accountPub: String                              /// Account public key that must be included in the subject
    350     var transferOptionsExt: AccountDetailsArray         /// Options for making the KYC auth transfer payment to the exchange
    351     var transferExpiry: Timestamp?                      /// Validity of the transferOptions if they might expire
    352 //    var amount: Amount                    // deprecated, use transferOptionsExt
    353 //    var creditPaytoUris: [String]         // deprecated
    354 }
    355 
    356 struct TransactionCommon: Decodable, Sendable {
    357     var type: TransactionType
    358     var transactionId: String
    359     var timestamp: Timestamp
    360     var scopes: [ScopeInfo]
    361     var txState: TransactionState
    362     var txActions: [TxAction]
    363     var amountRaw: Amount
    364     var amountEffective: Amount
    365     var error: TalerErrorDetail?
    366     var failReason: TalerErrorDetail?       // only for isFailed TODO: i18n
    367     var kycUrl: String?
    368     var kycAuthTransferInfo: KycAuthTransferInfo?
    369 
    370     var isIncoming      : Bool { type == .withdrawal
    371                               || type == .refund
    372                               || type == .peerPullCredit
    373                               || type == .scanPushCredit }
    374     var isOutgoing      : Bool { type == .deposit
    375                               || type == .payment
    376                               || type == .peerPushDebit
    377                               || type == .scanPullDebit
    378                               || type == .recoup
    379                               || type == .denomLoss }
    380 
    381     var isPending       : Bool { txState.major == .pending }
    382     var isPendingReady  : Bool { isPending && txState.isReady }
    383     var isPendingKYC    : Bool { isPending && txState.isKYC }
    384     var isPendingKYCauth: Bool { isPending && txState.isKYCauth }
    385     var isFinalizing    : Bool { txState.major == .finalizing }
    386     var isDone          : Bool { txState.major == .done }
    387     var isWorking       : Bool { txState.isWorking }
    388     var isAborting      : Bool { txState.major == .aborting }
    389     var isAborted       : Bool { txState.major == .aborted }
    390     var isSuspended     : Bool { txState.major == .suspended }
    391     var isDialog        : Bool { txState.major == .dialog }
    392     var isAbSuspended   : Bool { txState.major == .suspendedAborting }
    393     var isFailed        : Bool { txState.major == .failed }
    394     var isExpired       : Bool { txState.major == .expired }
    395 
    396     var isAbortable     : Bool { txActions.contains(.abort) }
    397     var isFailable      : Bool { txActions.contains(.fail) }
    398     var isDeleteable    : Bool { txActions.contains(.delete) }
    399     var isRetryable     : Bool { txActions.contains(.retry) }
    400     var isResumable     : Bool { txActions.contains(.resume) }
    401     var isSuspendable   : Bool { txActions.contains(.suspend) }
    402 
    403     func localizedType(_ type: TransactionType) -> String {
    404         switch type {
    405             case .dummy:          return String(EMPTYSTRING)
    406             case .withdrawal:     return String(localized: "Withdrawal",
    407                                                   comment: "TransactionType")
    408             case .deposit:        return String(localized: "Deposit",
    409                                                   comment: "TransactionType")
    410             case .payment:        return String(localized: "Payment",
    411                                                   comment: "TransactionType")
    412             case .refund:         return String(localized: "Refund",
    413                                                   comment: "TransactionType")
    414             case .refresh:        return String(localized: "Refresh",
    415                                                   comment: "TransactionType")
    416             case .peerPushDebit:  return String(localized: "Send Money",
    417                                                   comment: "TransactionType, send coins to another wallet")
    418             case .scanPushCredit: return String(localized: "Receive Money",
    419                                                   comment: "TransactionType, scan to receive coins sent from another wallet")
    420             case .peerPullCredit: return String(localized: "Request Money",     // Invoice?
    421                                                   comment: "TransactionType, send private 'invoice' to another wallet")
    422             case .scanPullDebit:  return String(localized: "Pay Request",       // Pay Invoice is the same as Payment
    423                                                   comment: "TransactionType, scan private 'invoice' to pay to another wallet")
    424             case .recoup:         return String(localized: "Recoup",
    425                                                   comment: "TransactionType")
    426             case .denomLoss:      return String(localized: "Money lost",
    427                                                   comment: "TransactionType")
    428         }
    429     }
    430     func localizedTypePast(_ type: TransactionType) -> String {
    431         switch type {
    432             case .peerPushDebit:  return String(localized: "Money Sent",
    433                                                 comment: "TransactionType, sent coins to another wallet")
    434             case .scanPushCredit: return String(localized: "Money Received",
    435                                                 comment: "TransactionType, received coins sent from another wallet")
    436             case .peerPullCredit: return String(localized: "Money Requested",     // Invoice?
    437                                                 comment: "TransactionType, sent private 'invoice' to another wallet")
    438             case .scanPullDebit:  return String(localized: "Request Paid",       // Pay Invoice is the same as Payment
    439                                                 comment: "TransactionType, paid private 'invoice' from another wallet")
    440             default:              return localizedType(type)
    441         }
    442     }
    443 
    444     func fee() -> Amount {
    445         do {
    446             return try Amount.diff(amountRaw, amountEffective)
    447         } catch {}
    448         do {
    449             return try Amount.diff(amountEffective, amountRaw)
    450         } catch {}
    451         return Amount.zero(currency: amountRaw.currencyStr)
    452     }
    453 }
    454 // MARK: - Withdrawal
    455 struct WithdrawalDetails: Codable {
    456     enum WithdrawalType: String, Codable {
    457         case manual = "manual-transfer"
    458         case bankIntegrated = "taler-bank-integration-api"
    459     }
    460     var type: WithdrawalType
    461     /// The public key of the reserve.
    462     var reservePub: String
    463     var reserveIsReady: Bool
    464     var exchangeCreditAccountDetails: [ExchangeAccountDetails]?
    465 
    466   /// Details for manual withdrawals:
    467     var reserveClosingDelay: Duration?
    468 
    469   /// Details for bank-integrated withdrawals:
    470     /// Whether the bank has confirmed the withdrawal.
    471     var confirmed: Bool?
    472     /// URL for user-initiated confirmation
    473     var bankConfirmationUrl: String?
    474 }
    475 struct WithdrawalTransactionDetails: Decodable {
    476     var exchangeBaseUrl: String?
    477     var withdrawalDetails: WithdrawalDetails
    478 }
    479 struct WithdrawalTransaction : Sendable {
    480     var common: TransactionCommon
    481     var details: WithdrawalTransactionDetails
    482 }
    483 // MARK: - Deposit
    484 struct TrackingState : Decodable {
    485     var wireTransferId: String
    486     var timestampExecuted: Timestamp
    487     var amountRaw: Amount
    488     var wireFee: Amount
    489 }
    490 struct DepositTransactionDetails: Decodable {
    491     var depositGroupId: String
    492     var targetPaytoUri: String
    493     /// percentage of coins already wired - fractional, e.g. 33.33… for 1 of 3 coins
    494     var wireTransferProgress: Double?
    495     var wireTransferDeadline: Timestamp
    496     var deposited: Bool
    497     var trackingState: [TrackingState]?
    498 }
    499 struct DepositTransaction : Sendable {
    500     var common: TransactionCommon
    501     var details: DepositTransactionDetails
    502 }
    503 // MARK: - Payment
    504 struct RefundInfo: Decodable {
    505     var amountEffective: Amount
    506     var amountRaw: Amount
    507     var transactionId: String
    508     var timestamp: Timestamp
    509 }
    510 struct PaymentTransactionDetails: Decodable {
    511     var info: OrderShortInfo?           // only if wallet-core already has it
    512     var totalRefundRaw: Amount
    513     var totalRefundEffective: Amount
    514     var refundPending: Amount?
    515     var refunds: [RefundInfo]?           // array of refund txIDs for this payment
    516     var refundQueryActive: Bool?
    517     var posConfirmation: String?
    518     var posConfirmationDeadline: Timestamp?
    519     var posConfirmationViaNfc: Bool?
    520     let contractTerms: MerchantContractTerms?       // TODO: show data in tx details
    521     let choiceIndex: Int?
    522     let repurchaseTransactionId: String?
    523     let abortReason: WalletBackendAbortReason?
    524 }
    525 struct PaymentTransaction : Sendable {
    526     var common: TransactionCommon
    527     var details: PaymentTransactionDetails
    528 }
    529 // MARK: - Refund
    530 struct RefundPaymentInfo: Decodable {
    531     var summary: String
    532     var summary_i18n: I18nDict?
    533     var merchant: MerchantInfo
    534 }
    535 
    536 struct RefundTransactionDetails: Decodable {
    537     var refundedTransactionId: String
    538     var paymentInfo: RefundPaymentInfo?
    539 }
    540 struct RefundTransaction : Sendable {
    541     var common: TransactionCommon
    542     var details: RefundTransactionDetails
    543 }
    544 // MARK: - Refresh
    545 enum RefreshReason: String, Decodable {
    546     case manual
    547     case payMerchant = "pay-merchant"
    548     case payDeposit = "pay-deposit"
    549     case payPeerPush = "pay-peer-push"
    550     case payPeerPull = "pay-peer-pull"
    551     case refund
    552     case abortPay = "abort-pay"
    553     case abortDeposit = "abort-deposit"
    554     case abortPeerPushDebit = "abort-peer-push-debit"
    555     case abortPeerPullDebit = "abort-peer-pull-debit"
    556     case recoup
    557     case backupRestored = "backup-restored"
    558     case scheduled
    559 
    560     var localizedRefreshReason: String {
    561         switch self {
    562             case .manual:               return String(localized: "Merchant",
    563                                                       comment: "RefreshReason")
    564             case .payMerchant:          return String(localized: "Merchant",
    565                                                     comment: "RefreshReason")
    566             case .payDeposit:           return String(localized: "Deposit",
    567                                                   comment: "RefreshReason")
    568             case .payPeerPush:          return String(localized: "Pay Peer-Push",
    569                                                   comment: "RefreshReason")
    570             case .payPeerPull:          return String(localized: "Pay Peer-Pull",
    571                                                   comment: "RefreshReason")
    572             case .refund:               return String(localized: "Refund",
    573                                                   comment: "RefreshReason")
    574             case .abortPay:             return String(localized: "Abort Payment",
    575                                                   comment: "RefreshReason")
    576             case .abortDeposit:         return String(localized: "Abort Deposit",
    577                                                   comment: "RefreshReason")
    578             case .abortPeerPushDebit:   return String(localized: "Abort Sending",
    579                                                     comment: "RefreshReason")
    580             case .abortPeerPullDebit:   return String(localized: "Abort Requesting",
    581                                                       comment: "RefreshReason")
    582             case .recoup:           return String(localized: "Recoup",
    583                                                   comment: "RefreshReason")
    584             case .backupRestored:   return String(localized: "Backup restored",
    585                                                   comment: "RefreshReason")
    586             case .scheduled:        return String(localized: "Scheduled",
    587                                                   comment: "RefreshReason")
    588         }
    589     }
    590 }
    591 struct RefreshError: Decodable {
    592     var code: Int
    593     var when: Timestamp
    594     var hint: String
    595     var stack: String?
    596     var numErrors: Int?                 // how many coins had errors
    597     var errors: [TalerErrorDetail]?     // 1..max(5, numErrors)
    598 }
    599 struct RefreshTransactionDetails: Decodable {
    600     var refreshReason: RefreshReason
    601     var originatingTransactionId: String?
    602     var refreshInputAmount: Amount
    603     var refreshOutputAmount: Amount
    604     var error: RefreshError?
    605 }
    606 struct RefreshTransaction : Sendable {
    607     var common: TransactionCommon
    608     var details: RefreshTransactionDetails
    609 }
    610 // MARK: - P2P
    611 struct P2pShortInfo: Codable, Sendable {
    612     var summary: String?
    613     var expiration: Timestamp?
    614     var iconId: String?
    615 }
    616 struct P2PTransactionDetails: Codable, Sendable {
    617     var exchangeBaseUrl: String
    618     var talerUri: String?       // only if we initiated the transaction
    619     var info: P2pShortInfo
    620 }
    621 struct P2PTransaction : Sendable {
    622     var common: TransactionCommon
    623     var details: P2PTransactionDetails
    624 }
    625 // MARK: - Recoup
    626 struct RecoupTransactionDetails: Decodable {
    627     var recoupReason: String?
    628 }
    629 struct RecoupTransaction : Sendable {
    630     var common: TransactionCommon
    631     var details: RecoupTransactionDetails
    632 }
    633 // MARK: - DenomLoss
    634 enum DenomLossEventType: String, Decodable {
    635     case denomExpired = "denom-expired"
    636     case denomVanished = "denom-vanished"
    637     case denomUnoffered = "denom-unoffered"
    638     case denomRevoked = "denom-revoked"      // exchange revoked the denomination
    639     case unknown                             // forward compatibility, see TransactionMinorState
    640 
    641     init(from decoder: Decoder) throws {
    642         let raw = try decoder.singleValueContainer().decode(String.self)
    643         self = DenomLossEventType(rawValue: raw) ?? .unknown
    644     }
    645 }
    646 struct DenomLossTransactionDetails: Decodable {
    647     var exchangeBaseUrl: String
    648     var lossEventType: DenomLossEventType
    649 }
    650 struct DenomLossTransaction : Sendable {
    651     var common: TransactionCommon
    652     var details: DenomLossTransactionDetails
    653 }
    654 // MARK: - Dummy
    655 struct DummyTransaction : Sendable {
    656     var common: TransactionCommon
    657 }
    658 // MARK: - Transaction
    659 enum TalerTransaction: Decodable, Hashable, Identifiable, Sendable {
    660     case dummy (DummyTransaction)
    661     case withdrawal (WithdrawalTransaction)
    662     case deposit (DepositTransaction)
    663     case payment (PaymentTransaction)
    664     case refund (RefundTransaction)
    665     case refresh (RefreshTransaction)
    666     case peer2peer (P2PTransaction)
    667     case recoup (RecoupTransaction)
    668     case denomLoss (DenomLossTransaction)
    669 
    670     init(from decoder: Decoder) throws {
    671         do {
    672             let common = try TransactionCommon.init(from: decoder)
    673             switch (common.type) {
    674                 case .withdrawal:
    675                     let details = try WithdrawalTransactionDetails.init(from: decoder)
    676                     self = .withdrawal(WithdrawalTransaction(common: common, details: details))
    677                 case .deposit:
    678                     let details = try DepositTransactionDetails.init(from: decoder)
    679                     self = .deposit(DepositTransaction(common: common, details: details))
    680                 case .payment:
    681                     let details = try PaymentTransactionDetails.init(from: decoder)
    682                     self = .payment(PaymentTransaction(common: common, details: details))
    683                 case .refund:
    684                     let details = try RefundTransactionDetails.init(from: decoder)
    685                     self = .refund(RefundTransaction(common: common, details: details))
    686                 case .refresh:
    687                     let details = try RefreshTransactionDetails.init(from: decoder)
    688                     self = .refresh(RefreshTransaction(common: common, details: details))
    689                 case .peerPushDebit, .peerPullCredit, .scanPullDebit, .scanPushCredit:
    690                     let details = try P2PTransactionDetails.init(from: decoder)
    691                     self = .peer2peer(P2PTransaction(common: common, details: details))
    692                 case .recoup:
    693                     let details = try RecoupTransactionDetails.init(from: decoder)
    694                     self = .recoup(RecoupTransaction(common: common, details: details))
    695                 case .denomLoss:
    696                     let details = try DenomLossTransactionDetails.init(from: decoder)
    697                     self = .denomLoss(DenomLossTransaction(common: common, details: details))
    698                 default:
    699                     let context = DecodingError.Context(
    700                         codingPath: decoder.codingPath,
    701                         debugDescription: "Invalid transaction type")
    702                     throw DecodingError.typeMismatch(Transaction.self, context)
    703             }
    704             return
    705         } catch DecodingError.dataCorrupted(let context) {
    706             print(context)
    707             throw TransactionDecodingError.invalidStringValue
    708         } catch DecodingError.keyNotFound(let key, let context) {
    709             print("Key '\(key)' not found:", context.debugDescription)
    710             print("codingPath:", context.codingPath)
    711             throw TransactionDecodingError.invalidStringValue
    712         } catch DecodingError.valueNotFound(let value, let context) {
    713             print("Value '\(value)' not found:", context.debugDescription)
    714             print("codingPath:", context.codingPath)
    715             throw TransactionDecodingError.invalidStringValue
    716         } catch DecodingError.typeMismatch(let type, let context) {
    717             print("Type '\(type)' mismatch:", context.debugDescription)
    718             print("codingPath:", context.codingPath)
    719             throw TransactionDecodingError.invalidStringValue
    720         } catch {       // TODO: native logging
    721             print("error: ", error)
    722             throw error
    723         }
    724     }
    725 
    726     var id: String { common.transactionId }
    727 
    728     var localizedType: String {
    729         common.localizedType(common.type)
    730     }
    731     var localizedTypePast: String {
    732         common.localizedTypePast(common.type)
    733     }
    734 
    735     static func == (lhs: TalerTransaction, rhs: TalerTransaction) -> Bool {
    736         return (lhs.id == rhs.id)
    737             && (lhs.common.txState == rhs.common.txState)
    738     }
    739 
    740     func hash(into hasher: inout Hasher) {
    741         hasher.combine(id)
    742         hasher.combine(common.txState)        // let SwiftUI redraw if txState changes
    743     }
    744 
    745     var isWithdrawal : Bool { common.type == .withdrawal }
    746     var isDeposit    : Bool { common.type == .deposit }
    747     var isPayment    : Bool { common.type == .payment }
    748     var isRefund     : Bool { common.type == .refund }
    749     var isRefresh    : Bool { common.type == .refresh }
    750     var isSendCoins  : Bool { common.type == .peerPushDebit }
    751     var isRcvCoins   : Bool { common.type == .scanPushCredit }
    752     var isSendInvoice: Bool { common.type == .peerPullCredit }
    753     var isPayInvoice : Bool { common.type == .scanPullDebit }
    754 
    755     var isP2pOutgoing: Bool { isSendCoins || isPayInvoice}
    756     var isP2pIncoming: Bool { isSendInvoice || isRcvCoins}
    757 
    758     var isPending       : Bool { common.isPending }
    759     var isPendingReady  : Bool { common.isPendingReady }
    760     var isPendingKYC    : Bool { common.isPendingKYC }
    761     var isPendingKYCauth: Bool { common.isPendingKYCauth }
    762     var isDone          : Bool { common.isDone }
    763     var isWorking       : Bool { common.isWorking }
    764     var isAborting      : Bool { common.isAborting }
    765     var isAborted       : Bool { common.isAborted }
    766     var isSuspended     : Bool { common.isSuspended }
    767     var isDialog        : Bool { common.isDialog }
    768     var isAbSuspended   : Bool { common.isAbSuspended }
    769     var isFailed        : Bool { common.isFailed }
    770     var isExpired       : Bool { common.isExpired }
    771 
    772     var isAbortable     : Bool { common.isAbortable }
    773     var isFailable      : Bool { common.isFailable }
    774     var isDeleteable    : Bool { common.isDeleteable }
    775     var isRetryable     : Bool { common.isRetryable }
    776     var isResumable     : Bool { common.isResumable }
    777     var isSuspendable   : Bool { common.isSuspendable }
    778 
    779     var shouldConfirm: Bool {
    780         switch self {
    781             case .withdrawal(let withdrawalTransaction):
    782                 let details = withdrawalTransaction.details.withdrawalDetails
    783                 guard details.bankConfirmationUrl != nil else { return false }
    784                 if let confirmed = details.confirmed {
    785                     return details.type == .bankIntegrated && confirmed == false
    786                 }
    787             default: break
    788         }
    789         return false
    790     }
    791     var common: TransactionCommon {
    792         return switch self {
    793             case .dummy(let dummyTransaction):           dummyTransaction.common
    794             case .withdrawal(let withdrawalTransaction): withdrawalTransaction.common
    795             case .deposit(let depositTransaction):       depositTransaction.common
    796             case .payment(let paymentTransaction):       paymentTransaction.common
    797             case .refund(let refundTransaction):         refundTransaction.common
    798             case .refresh(let refreshTransaction):       refreshTransaction.common
    799             case .peer2peer(let p2pTransaction):         p2pTransaction.common
    800             case .recoup(let recoupTransaction):         recoupTransaction.common
    801             case .denomLoss(let denomLossTransaction):   denomLossTransaction.common
    802         }
    803     }
    804 
    805     func amount() -> Amount {
    806         switch self {
    807             case .refresh(let refreshTransaction):
    808                 let details = refreshTransaction.details
    809                 return details.refreshInputAmount
    810             default:
    811                 let eff = common.amountEffective
    812                 if !eff.isZero { return eff }
    813                 return common.amountRaw
    814         }
    815     }
    816 
    817     func detailsToShow() -> Dictionary<String, String> {
    818         var result: [String:String] = [:]
    819         switch self {
    820             case .dummy(_):  // let dummyTransaction
    821                 break
    822             case .withdrawal(let withdrawalTransaction):
    823                 result[EXCHANGEBASEURL] = withdrawalTransaction.details.exchangeBaseUrl
    824             case .deposit(let depositTransaction):
    825                 result[EXCHANGEBASEURL] = depositTransaction.details.depositGroupId
    826             case .payment(let paymentTransaction):
    827                 result["summary"] = paymentTransaction.details.info?.summary ?? EMPTYSTRING
    828             case .refund(let refundTransaction):
    829                 if let info = refundTransaction.details.paymentInfo {
    830                     result["summary"] = info.summary
    831                 }
    832             case .refresh(let refreshTransaction):
    833                 result["reason"] = refreshTransaction.details.refreshReason.rawValue
    834             case .peer2peer(let p2pTransaction):
    835                 result[EXCHANGEBASEURL] = p2pTransaction.details.exchangeBaseUrl
    836                 result["summary"] = p2pTransaction.details.info.summary
    837                 result[TALERURI] = p2pTransaction.details.talerUri ?? EMPTYSTRING
    838             case .recoup(let recoupTransaction):
    839                 result["reason"] = recoupTransaction.details.recoupReason
    840             case .denomLoss(let denomLossTransaction):
    841                 result[EXCHANGEBASEURL] = denomLossTransaction.details.exchangeBaseUrl
    842                 result["reason"] = denomLossTransaction.details.lossEventType.rawValue
    843         }
    844         return result
    845     }
    846 }