taler-ios

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

Transaction.swift (37154B)


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