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