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