taler-ios

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

Transaction.swift (40311B)


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