taler-ios

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

Transaction.swift (37286B)


      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     let contractTerms: MerchantContractTerms?       // TODO: show data in tx details
    482     let abortReason: WalletBackendAbortReason?
    483 }
    484 struct PaymentTransaction : Sendable {
    485     var common: TransactionCommon
    486     var details: PaymentTransactionDetails
    487 }
    488 // MARK: - Refund
    489 struct RefundTransactionDetails: Decodable {
    490     var refundedTransactionId: String
    491     var refundPending: Amount?
    492     /// The amount that couldn't be applied because refund permissions expired.
    493     var amountInvalid: Amount?
    494     var info: OrderShortInfo?       // TODO: is this still here?
    495 }
    496 struct RefundTransaction : Sendable {
    497     var common: TransactionCommon
    498     var details: RefundTransactionDetails
    499 }
    500 // MARK: - Refresh
    501 enum RefreshReason: String, Decodable {
    502     case manual
    503     case payMerchant = "pay-merchant"
    504     case payDeposit = "pay-deposit"
    505     case payPeerPush = "pay-peer-push"
    506     case payPeerPull = "pay-peer-pull"
    507     case refund
    508     case abortPay = "abort-pay"
    509     case abortDeposit = "abort-deposit"
    510     case abortPeerPushDebit = "abort-peer-push-debit"
    511     case recoup
    512     case backupRestored = "backup-restored"
    513     case scheduled
    514 
    515     var localizedRefreshReason: String {
    516         switch self {
    517             case .manual:               return String(localized: "Merchant",
    518                                                       comment: "RefreshReason")
    519             case .payMerchant:          return String(localized: "Merchant",
    520                                                     comment: "RefreshReason")
    521             case .payDeposit:           return String(localized: "Deposit",
    522                                                   comment: "RefreshReason")
    523             case .payPeerPush:          return String(localized: "Pay Peer-Push",
    524                                                   comment: "RefreshReason")
    525             case .payPeerPull:          return String(localized: "Pay Peer-Pull",
    526                                                   comment: "RefreshReason")
    527             case .refund:               return String(localized: "Refund",
    528                                                   comment: "RefreshReason")
    529             case .abortPay:             return String(localized: "Abort Payment",
    530                                                   comment: "RefreshReason")
    531             case .abortDeposit:         return String(localized: "Abort Deposit",
    532                                                   comment: "RefreshReason")
    533             case .abortPeerPushDebit:   return String(localized: "Abort Sending",
    534                                                     comment: "RefreshReason")
    535             case .recoup:           return String(localized: "Recoup",
    536                                                   comment: "RefreshReason")
    537             case .backupRestored:   return String(localized: "Backup restored",
    538                                                   comment: "RefreshReason")
    539             case .scheduled:        return String(localized: "Scheduled",
    540                                                   comment: "RefreshReason")
    541         }
    542     }
    543 }
    544 struct RefreshError: Decodable {
    545     var code: Int
    546     var when: Timestamp
    547     var hint: String
    548     var stack: String?
    549     var numErrors: Int?                 // how many coins had errors
    550     var errors: [TalerErrorDetail]?     // 1..max(5, numErrors)
    551 }
    552 struct RefreshTransactionDetails: Decodable {
    553     var refreshReason: RefreshReason
    554     var originatingTransactionId: String?
    555     var refreshInputAmount: Amount
    556     var refreshOutputAmount: Amount
    557     var error: RefreshError?
    558 }
    559 struct RefreshTransaction : Sendable {
    560     var common: TransactionCommon
    561     var details: RefreshTransactionDetails
    562 }
    563 // MARK: - P2P
    564 struct P2pShortInfo: Codable, Sendable {
    565     var summary: String
    566     var expiration: Timestamp
    567     var iconId: String?
    568 }
    569 struct P2PTransactionDetails: Codable, Sendable {
    570     var exchangeBaseUrl: String
    571     var talerUri: String?       // only if we initiated the transaction
    572     var info: P2pShortInfo
    573 }
    574 struct P2PTransaction : Sendable {
    575     var common: TransactionCommon
    576     var details: P2PTransactionDetails
    577 }
    578 // MARK: - Recoup
    579 struct RecoupTransactionDetails: Decodable {
    580     var recoupReason: String?
    581 }
    582 struct RecoupTransaction : Sendable {
    583     var common: TransactionCommon
    584     var details: RecoupTransactionDetails
    585 }
    586 // MARK: - DenomLoss
    587 enum DenomLossEventType: String, Decodable {
    588     case denomExpired = "denom-expired"
    589     case denomVanished = "denom-vanished"
    590     case denomUnoffered = "denom-unoffered"
    591 }
    592 struct DenomLossTransactionDetails: Decodable {
    593     var exchangeBaseUrl: String
    594     var lossEventType: DenomLossEventType
    595 }
    596 struct DenomLossTransaction : Sendable {
    597     var common: TransactionCommon
    598     var details: DenomLossTransactionDetails
    599 }
    600 // MARK: - Dummy
    601 struct DummyTransaction : Sendable {
    602     var common: TransactionCommon
    603 }
    604 // MARK: - Transaction
    605 enum TalerTransaction: Decodable, Hashable, Identifiable, Sendable {
    606     case dummy (DummyTransaction)
    607     case withdrawal (WithdrawalTransaction)
    608     case deposit (DepositTransaction)
    609     case payment (PaymentTransaction)
    610     case refund (RefundTransaction)
    611     case refresh (RefreshTransaction)
    612     case peer2peer (P2PTransaction)
    613     case recoup (RecoupTransaction)
    614     case denomLoss (DenomLossTransaction)
    615 
    616     init(from decoder: Decoder) throws {
    617         do {
    618             let common = try TransactionCommon.init(from: decoder)
    619             switch (common.type) {
    620                 case .withdrawal:
    621                     let details = try WithdrawalTransactionDetails.init(from: decoder)
    622                     self = .withdrawal(WithdrawalTransaction(common: common, details: details))
    623                 case .deposit:
    624                     let details = try DepositTransactionDetails.init(from: decoder)
    625                     self = .deposit(DepositTransaction(common: common, details: details))
    626                 case .payment:
    627                     let details = try PaymentTransactionDetails.init(from: decoder)
    628                     self = .payment(PaymentTransaction(common: common, details: details))
    629                 case .refund:
    630                     let details = try RefundTransactionDetails.init(from: decoder)
    631                     self = .refund(RefundTransaction(common: common, details: details))
    632                 case .refresh:
    633                     let details = try RefreshTransactionDetails.init(from: decoder)
    634                     self = .refresh(RefreshTransaction(common: common, details: details))
    635                 case .peerPushDebit, .peerPullCredit, .scanPullDebit, .scanPushCredit:
    636                     let details = try P2PTransactionDetails.init(from: decoder)
    637                     self = .peer2peer(P2PTransaction(common: common, details: details))
    638                 case .recoup:
    639                     let details = try RecoupTransactionDetails.init(from: decoder)
    640                     self = .recoup(RecoupTransaction(common: common, details: details))
    641                 case .denomLoss:
    642                     let details = try DenomLossTransactionDetails.init(from: decoder)
    643                     self = .denomLoss(DenomLossTransaction(common: common, details: details))
    644                 default:
    645                     let context = DecodingError.Context(
    646                         codingPath: decoder.codingPath,
    647                         debugDescription: "Invalid transaction type")
    648                     throw DecodingError.typeMismatch(Transaction.self, context)
    649             }
    650             return
    651         } catch DecodingError.dataCorrupted(let context) {
    652             print(context)
    653             throw TransactionDecodingError.invalidStringValue
    654         } catch DecodingError.keyNotFound(let key, let context) {
    655             print("Key '\(key)' not found:", context.debugDescription)
    656             print("codingPath:", context.codingPath)
    657             throw TransactionDecodingError.invalidStringValue
    658         } catch DecodingError.valueNotFound(let value, let context) {
    659             print("Value '\(value)' not found:", context.debugDescription)
    660             print("codingPath:", context.codingPath)
    661             throw TransactionDecodingError.invalidStringValue
    662         } catch DecodingError.typeMismatch(let type, let context) {
    663             print("Type '\(type)' mismatch:", context.debugDescription)
    664             print("codingPath:", context.codingPath)
    665             throw TransactionDecodingError.invalidStringValue
    666         } catch {       // TODO: native logging
    667             print("error: ", error)
    668             throw error
    669         }
    670     }
    671 
    672     var id: String { common.transactionId }
    673 
    674     var localizedType: String {
    675         common.localizedType(common.type)
    676     }
    677     var localizedTypePast: String {
    678         common.localizedTypePast(common.type)
    679     }
    680 
    681     static func == (lhs: TalerTransaction, rhs: TalerTransaction) -> Bool {
    682         return (lhs.id == rhs.id)
    683             && (lhs.common.txState == rhs.common.txState)
    684     }
    685 
    686     func hash(into hasher: inout Hasher) {
    687         hasher.combine(id)
    688         hasher.combine(common.txState)        // let SwiftUI redraw if txState changes
    689     }
    690 
    691     var isWithdrawal : Bool { common.type == .withdrawal }
    692     var isDeposit    : Bool { common.type == .deposit }
    693     var isPayment    : Bool { common.type == .payment }
    694     var isRefund     : Bool { common.type == .refund }
    695     var isRefresh    : Bool { common.type == .refresh }
    696     var isSendCoins  : Bool { common.type == .peerPushDebit }
    697     var isRcvCoins   : Bool { common.type == .scanPushCredit }
    698     var isSendInvoice: Bool { common.type == .peerPullCredit }
    699     var isPayInvoice : Bool { common.type == .scanPullDebit }
    700 
    701     var isP2pOutgoing: Bool { isSendCoins || isPayInvoice}
    702     var isP2pIncoming: Bool { isSendInvoice || isRcvCoins}
    703 
    704     var isPending       : Bool { common.isPending }
    705     var isPendingReady  : Bool { common.isPendingReady }
    706     var isPendingKYC    : Bool { common.isPendingKYC }
    707     var isPendingKYCauth: Bool { common.isPendingKYCauth }
    708     var isDone          : Bool { common.isDone }
    709     var isAborting      : Bool { common.isAborting }
    710     var isAborted       : Bool { common.isAborted }
    711     var isSuspended     : Bool { common.isSuspended }
    712     var isDialog        : Bool { common.isDialog }
    713     var isAbSuspended   : Bool { common.isAbSuspended }
    714     var isFailed        : Bool { common.isFailed }
    715     var isExpired       : Bool { common.isExpired }
    716 
    717     var isAbortable     : Bool { common.isAbortable }
    718     var isFailable      : Bool { common.isFailable }
    719     var isDeleteable    : Bool { common.isDeleteable }
    720     var isRetryable     : Bool { common.isRetryable }
    721     var isResumable     : Bool { common.isResumable }
    722     var isSuspendable   : Bool { common.isSuspendable }
    723 
    724     var shouldConfirm: Bool {
    725         switch self {
    726             case .withdrawal(let withdrawalTransaction):
    727                 let details = withdrawalTransaction.details.withdrawalDetails
    728                 guard details.bankConfirmationUrl != nil else { return false }
    729                 if let confirmed = details.confirmed {
    730                     return details.type == .bankIntegrated && confirmed == false
    731                 }
    732             default: break
    733         }
    734         return false
    735     }
    736     var common: TransactionCommon {
    737         return switch self {
    738             case .dummy(let dummyTransaction):           dummyTransaction.common
    739             case .withdrawal(let withdrawalTransaction): withdrawalTransaction.common
    740             case .deposit(let depositTransaction):       depositTransaction.common
    741             case .payment(let paymentTransaction):       paymentTransaction.common
    742             case .refund(let refundTransaction):         refundTransaction.common
    743             case .refresh(let refreshTransaction):       refreshTransaction.common
    744             case .peer2peer(let p2pTransaction):         p2pTransaction.common
    745             case .recoup(let recoupTransaction):         recoupTransaction.common
    746             case .denomLoss(let denomLossTransaction):   denomLossTransaction.common
    747         }
    748     }
    749 
    750     func amount() -> Amount {
    751         switch self {
    752             case .refresh(let refreshTransaction):
    753                 let details = refreshTransaction.details
    754                 return details.refreshInputAmount
    755             default:
    756                 let eff = common.amountEffective
    757                 if !eff.isZero { return eff }
    758                 return common.amountRaw
    759         }
    760     }
    761 
    762     func detailsToShow() -> Dictionary<String, String> {
    763         var result: [String:String] = [:]
    764         switch self {
    765             case .dummy(_):  // let dummyTransaction
    766                 break
    767             case .withdrawal(let withdrawalTransaction):
    768                 result[EXCHANGEBASEURL] = withdrawalTransaction.details.exchangeBaseUrl
    769             case .deposit(let depositTransaction):
    770                 result[EXCHANGEBASEURL] = depositTransaction.details.depositGroupId
    771             case .payment(let paymentTransaction):
    772                 result["summary"] = paymentTransaction.details.info.summary
    773             case .refund(let refundTransaction):
    774                 if let info = refundTransaction.details.info {
    775                     result["summary"] = info.summary
    776                 }
    777             case .refresh(let refreshTransaction):
    778                 result["reason"] = refreshTransaction.details.refreshReason.rawValue
    779             case .peer2peer(let p2pTransaction):
    780                 result[EXCHANGEBASEURL] = p2pTransaction.details.exchangeBaseUrl
    781                 result["summary"] = p2pTransaction.details.info.summary
    782                 result[TALERURI] = p2pTransaction.details.talerUri ?? EMPTYSTRING
    783             case .recoup(let recoupTransaction):
    784                 result["reason"] = recoupTransaction.details.recoupReason
    785             case .denomLoss(let denomLossTransaction):
    786                 result[EXCHANGEBASEURL] = denomLossTransaction.details.exchangeBaseUrl
    787                 result["reason"] = denomLossTransaction.details.lossEventType.rawValue
    788         }
    789         return result
    790     }
    791 }