Transaction.swift (37286B)
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 let contractTerms: MerchantContractTerms? // TODO: show data in tx details 482 let abortReason: WalletBackendAbortReason? 483 } 484 struct PaymentTransaction : Sendable { 485 var common: TransactionCommon 486 var details: PaymentTransactionDetails 487 } 488 // MARK: - Refund 489 struct RefundTransactionDetails: Decodable { 490 var refundedTransactionId: String 491 var refundPending: Amount? 492 /// The amount that couldn't be applied because refund permissions expired. 493 var amountInvalid: Amount? 494 var info: OrderShortInfo? // TODO: is this still here? 495 } 496 struct RefundTransaction : Sendable { 497 var common: TransactionCommon 498 var details: RefundTransactionDetails 499 } 500 // MARK: - Refresh 501 enum RefreshReason: String, Decodable { 502 case manual 503 case payMerchant = "pay-merchant" 504 case payDeposit = "pay-deposit" 505 case payPeerPush = "pay-peer-push" 506 case payPeerPull = "pay-peer-pull" 507 case refund 508 case abortPay = "abort-pay" 509 case abortDeposit = "abort-deposit" 510 case abortPeerPushDebit = "abort-peer-push-debit" 511 case recoup 512 case backupRestored = "backup-restored" 513 case scheduled 514 515 var localizedRefreshReason: String { 516 switch self { 517 case .manual: return String(localized: "Merchant", 518 comment: "RefreshReason") 519 case .payMerchant: return String(localized: "Merchant", 520 comment: "RefreshReason") 521 case .payDeposit: return String(localized: "Deposit", 522 comment: "RefreshReason") 523 case .payPeerPush: return String(localized: "Pay Peer-Push", 524 comment: "RefreshReason") 525 case .payPeerPull: return String(localized: "Pay Peer-Pull", 526 comment: "RefreshReason") 527 case .refund: return String(localized: "Refund", 528 comment: "RefreshReason") 529 case .abortPay: return String(localized: "Abort Payment", 530 comment: "RefreshReason") 531 case .abortDeposit: return String(localized: "Abort Deposit", 532 comment: "RefreshReason") 533 case .abortPeerPushDebit: return String(localized: "Abort Sending", 534 comment: "RefreshReason") 535 case .recoup: return String(localized: "Recoup", 536 comment: "RefreshReason") 537 case .backupRestored: return String(localized: "Backup restored", 538 comment: "RefreshReason") 539 case .scheduled: return String(localized: "Scheduled", 540 comment: "RefreshReason") 541 } 542 } 543 } 544 struct RefreshError: Decodable { 545 var code: Int 546 var when: Timestamp 547 var hint: String 548 var stack: String? 549 var numErrors: Int? // how many coins had errors 550 var errors: [TalerErrorDetail]? // 1..max(5, numErrors) 551 } 552 struct RefreshTransactionDetails: Decodable { 553 var refreshReason: RefreshReason 554 var originatingTransactionId: String? 555 var refreshInputAmount: Amount 556 var refreshOutputAmount: Amount 557 var error: RefreshError? 558 } 559 struct RefreshTransaction : Sendable { 560 var common: TransactionCommon 561 var details: RefreshTransactionDetails 562 } 563 // MARK: - P2P 564 struct P2pShortInfo: Codable, Sendable { 565 var summary: String 566 var expiration: Timestamp 567 var iconId: String? 568 } 569 struct P2PTransactionDetails: Codable, Sendable { 570 var exchangeBaseUrl: String 571 var talerUri: String? // only if we initiated the transaction 572 var info: P2pShortInfo 573 } 574 struct P2PTransaction : Sendable { 575 var common: TransactionCommon 576 var details: P2PTransactionDetails 577 } 578 // MARK: - Recoup 579 struct RecoupTransactionDetails: Decodable { 580 var recoupReason: String? 581 } 582 struct RecoupTransaction : Sendable { 583 var common: TransactionCommon 584 var details: RecoupTransactionDetails 585 } 586 // MARK: - DenomLoss 587 enum DenomLossEventType: String, Decodable { 588 case denomExpired = "denom-expired" 589 case denomVanished = "denom-vanished" 590 case denomUnoffered = "denom-unoffered" 591 } 592 struct DenomLossTransactionDetails: Decodable { 593 var exchangeBaseUrl: String 594 var lossEventType: DenomLossEventType 595 } 596 struct DenomLossTransaction : Sendable { 597 var common: TransactionCommon 598 var details: DenomLossTransactionDetails 599 } 600 // MARK: - Dummy 601 struct DummyTransaction : Sendable { 602 var common: TransactionCommon 603 } 604 // MARK: - Transaction 605 enum TalerTransaction: Decodable, Hashable, Identifiable, Sendable { 606 case dummy (DummyTransaction) 607 case withdrawal (WithdrawalTransaction) 608 case deposit (DepositTransaction) 609 case payment (PaymentTransaction) 610 case refund (RefundTransaction) 611 case refresh (RefreshTransaction) 612 case peer2peer (P2PTransaction) 613 case recoup (RecoupTransaction) 614 case denomLoss (DenomLossTransaction) 615 616 init(from decoder: Decoder) throws { 617 do { 618 let common = try TransactionCommon.init(from: decoder) 619 switch (common.type) { 620 case .withdrawal: 621 let details = try WithdrawalTransactionDetails.init(from: decoder) 622 self = .withdrawal(WithdrawalTransaction(common: common, details: details)) 623 case .deposit: 624 let details = try DepositTransactionDetails.init(from: decoder) 625 self = .deposit(DepositTransaction(common: common, details: details)) 626 case .payment: 627 let details = try PaymentTransactionDetails.init(from: decoder) 628 self = .payment(PaymentTransaction(common: common, details: details)) 629 case .refund: 630 let details = try RefundTransactionDetails.init(from: decoder) 631 self = .refund(RefundTransaction(common: common, details: details)) 632 case .refresh: 633 let details = try RefreshTransactionDetails.init(from: decoder) 634 self = .refresh(RefreshTransaction(common: common, details: details)) 635 case .peerPushDebit, .peerPullCredit, .scanPullDebit, .scanPushCredit: 636 let details = try P2PTransactionDetails.init(from: decoder) 637 self = .peer2peer(P2PTransaction(common: common, details: details)) 638 case .recoup: 639 let details = try RecoupTransactionDetails.init(from: decoder) 640 self = .recoup(RecoupTransaction(common: common, details: details)) 641 case .denomLoss: 642 let details = try DenomLossTransactionDetails.init(from: decoder) 643 self = .denomLoss(DenomLossTransaction(common: common, details: details)) 644 default: 645 let context = DecodingError.Context( 646 codingPath: decoder.codingPath, 647 debugDescription: "Invalid transaction type") 648 throw DecodingError.typeMismatch(Transaction.self, context) 649 } 650 return 651 } catch DecodingError.dataCorrupted(let context) { 652 print(context) 653 throw TransactionDecodingError.invalidStringValue 654 } catch DecodingError.keyNotFound(let key, let context) { 655 print("Key '\(key)' not found:", context.debugDescription) 656 print("codingPath:", context.codingPath) 657 throw TransactionDecodingError.invalidStringValue 658 } catch DecodingError.valueNotFound(let value, let context) { 659 print("Value '\(value)' not found:", context.debugDescription) 660 print("codingPath:", context.codingPath) 661 throw TransactionDecodingError.invalidStringValue 662 } catch DecodingError.typeMismatch(let type, let context) { 663 print("Type '\(type)' mismatch:", context.debugDescription) 664 print("codingPath:", context.codingPath) 665 throw TransactionDecodingError.invalidStringValue 666 } catch { // TODO: native logging 667 print("error: ", error) 668 throw error 669 } 670 } 671 672 var id: String { common.transactionId } 673 674 var localizedType: String { 675 common.localizedType(common.type) 676 } 677 var localizedTypePast: String { 678 common.localizedTypePast(common.type) 679 } 680 681 static func == (lhs: TalerTransaction, rhs: TalerTransaction) -> Bool { 682 return (lhs.id == rhs.id) 683 && (lhs.common.txState == rhs.common.txState) 684 } 685 686 func hash(into hasher: inout Hasher) { 687 hasher.combine(id) 688 hasher.combine(common.txState) // let SwiftUI redraw if txState changes 689 } 690 691 var isWithdrawal : Bool { common.type == .withdrawal } 692 var isDeposit : Bool { common.type == .deposit } 693 var isPayment : Bool { common.type == .payment } 694 var isRefund : Bool { common.type == .refund } 695 var isRefresh : Bool { common.type == .refresh } 696 var isSendCoins : Bool { common.type == .peerPushDebit } 697 var isRcvCoins : Bool { common.type == .scanPushCredit } 698 var isSendInvoice: Bool { common.type == .peerPullCredit } 699 var isPayInvoice : Bool { common.type == .scanPullDebit } 700 701 var isP2pOutgoing: Bool { isSendCoins || isPayInvoice} 702 var isP2pIncoming: Bool { isSendInvoice || isRcvCoins} 703 704 var isPending : Bool { common.isPending } 705 var isPendingReady : Bool { common.isPendingReady } 706 var isPendingKYC : Bool { common.isPendingKYC } 707 var isPendingKYCauth: Bool { common.isPendingKYCauth } 708 var isDone : Bool { common.isDone } 709 var isAborting : Bool { common.isAborting } 710 var isAborted : Bool { common.isAborted } 711 var isSuspended : Bool { common.isSuspended } 712 var isDialog : Bool { common.isDialog } 713 var isAbSuspended : Bool { common.isAbSuspended } 714 var isFailed : Bool { common.isFailed } 715 var isExpired : Bool { common.isExpired } 716 717 var isAbortable : Bool { common.isAbortable } 718 var isFailable : Bool { common.isFailable } 719 var isDeleteable : Bool { common.isDeleteable } 720 var isRetryable : Bool { common.isRetryable } 721 var isResumable : Bool { common.isResumable } 722 var isSuspendable : Bool { common.isSuspendable } 723 724 var shouldConfirm: Bool { 725 switch self { 726 case .withdrawal(let withdrawalTransaction): 727 let details = withdrawalTransaction.details.withdrawalDetails 728 guard details.bankConfirmationUrl != nil else { return false } 729 if let confirmed = details.confirmed { 730 return details.type == .bankIntegrated && confirmed == false 731 } 732 default: break 733 } 734 return false 735 } 736 var common: TransactionCommon { 737 return switch self { 738 case .dummy(let dummyTransaction): dummyTransaction.common 739 case .withdrawal(let withdrawalTransaction): withdrawalTransaction.common 740 case .deposit(let depositTransaction): depositTransaction.common 741 case .payment(let paymentTransaction): paymentTransaction.common 742 case .refund(let refundTransaction): refundTransaction.common 743 case .refresh(let refreshTransaction): refreshTransaction.common 744 case .peer2peer(let p2pTransaction): p2pTransaction.common 745 case .recoup(let recoupTransaction): recoupTransaction.common 746 case .denomLoss(let denomLossTransaction): denomLossTransaction.common 747 } 748 } 749 750 func amount() -> Amount { 751 switch self { 752 case .refresh(let refreshTransaction): 753 let details = refreshTransaction.details 754 return details.refreshInputAmount 755 default: 756 let eff = common.amountEffective 757 if !eff.isZero { return eff } 758 return common.amountRaw 759 } 760 } 761 762 func detailsToShow() -> Dictionary<String, String> { 763 var result: [String:String] = [:] 764 switch self { 765 case .dummy(_): // let dummyTransaction 766 break 767 case .withdrawal(let withdrawalTransaction): 768 result[EXCHANGEBASEURL] = withdrawalTransaction.details.exchangeBaseUrl 769 case .deposit(let depositTransaction): 770 result[EXCHANGEBASEURL] = depositTransaction.details.depositGroupId 771 case .payment(let paymentTransaction): 772 result["summary"] = paymentTransaction.details.info.summary 773 case .refund(let refundTransaction): 774 if let info = refundTransaction.details.info { 775 result["summary"] = info.summary 776 } 777 case .refresh(let refreshTransaction): 778 result["reason"] = refreshTransaction.details.refreshReason.rawValue 779 case .peer2peer(let p2pTransaction): 780 result[EXCHANGEBASEURL] = p2pTransaction.details.exchangeBaseUrl 781 result["summary"] = p2pTransaction.details.info.summary 782 result[TALERURI] = p2pTransaction.details.talerUri ?? EMPTYSTRING 783 case .recoup(let recoupTransaction): 784 result["reason"] = recoupTransaction.details.recoupReason 785 case .denomLoss(let denomLossTransaction): 786 result[EXCHANGEBASEURL] = denomLossTransaction.details.exchangeBaseUrl 787 result["reason"] = denomLossTransaction.details.lossEventType.rawValue 788 } 789 return result 790 } 791 }