taler-ios

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

Transaction.swift (38549B)


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