taler-ios

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

Transaction.swift (37117B)


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