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 }