taler-ios

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

Transaction.swift (37481B)


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