taler-ios

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

Transaction.swift (40620B)


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