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