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