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 }