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