taler-ios

iOS apps for GNU Taler (wallet)
Log | Files | Refs | README | LICENSE

commit cc281301979d4b0ed3732522c6b95057c9e89c89
parent a4069f8e5d21184172daa774f528334d5798fcd6
Author: Marc Stibane <marc@taler.net>
Date:   Mon, 20 Feb 2023 15:13:43 +0100

Transaction definition and JSON decoding - Bug 7678

Diffstat:
MTalerWallet1/Backend/Transaction.swift | 384+++++++++++++++++++++++++++++--------------------------------------------------
MTalerWallet1/Views/Transactions/TransactionDetail.swift | 29+++++++++++++++--------------
MTalerWallet1/Views/Transactions/TransactionRow.swift | 22+++++++++++++---------
MTalerWallet1/Views/Transactions/TransactionsListView.swift | 7++++---
4 files changed, 174 insertions(+), 268 deletions(-)

diff --git a/TalerWallet1/Backend/Transaction.swift b/TalerWallet1/Backend/Transaction.swift @@ -22,293 +22,193 @@ enum TransactionTypeError: Error { case unknownTypeError } -/// Different types of transactions. -enum TransactionType: Codable { - case withdrawal - case payment - case refund - case tip - case refresh - - init(from decoder: Decoder) throws { - let value = try decoder.singleValueContainer() - let str = try value.decode(String.self) - let codingNames = [ - "TransactionWithdrawal" : TransactionType.withdrawal, - "TransactionPayment" : TransactionType.payment, - "TransactionRefund" : TransactionType.refund, - "TransactionTip" : TransactionType.tip, - "TransactionRefresh" : TransactionType.refresh - ] - if let type = codingNames[str] { - self = type - } else { - throw TransactionTypeError.unknownTypeError - } - } - - func encode(to encoder: Encoder) throws { - var value = encoder.singleValueContainer() - switch self { - case .withdrawal: - try value.encode("TransactionWithdrawal") - case .payment: - try value.encode("TransactionPayment") - case .refund: - try value.encode("TransactionRefund") - case .tip: - try value.encode("TransactionTip") - case .refresh: - try value.encode("TransactionRefresh") - } - } -} - enum TransactionDecodingError: Error { case invalidStringValue } -/// Details for a manual withdrawal. -struct ManualWithdrawalDetails: Codable { - /// The payto URIs that the exchange supports. - var exchangePaytoUris: [String] - - /// The public key of the newly created reserve. - var reservePub: String +struct TransactionCommon: Decodable { + var type: String + var amountEffective: Amount + var amountRaw: Amount + var transactionId: String + var timestamp: Timestamp + var extendedStatus: String // TODO: enum with some fixed values? + var pending: Bool + var frozen: Bool } -/// Details for a bank-integrated withdrawal. -struct BankIntegratedWithdrawalDetails: Codable { - /// Whether the bank has confirmed the withdrawal. - var confirmed: Bool +struct WithdrawalDetails: Decodable { + enum WithdrawalType: String, Decodable { + case manual = "manual-transfer" + case bankIntegrated = "taler-bank-integration-api" + case peerPullCredit = "peer-pull-credit" + case peerPushCredit = "peer-push-credit" + } + var type: WithdrawalType + /// The public key of the reserve. + var reservePub: String - /// The public key of the newly created reserve. - var reservePub: String? + /// Details for manual withdrawals: + /// The payto URIs that the exchange supports. + var exchangePaytoUris: [String]? + /// Details for bank-integrated withdrawals: + /// Whether the bank has confirmed the withdrawal. + var confirmed: Bool? /// URL for user-initiated confirmation var bankConfirmationUrl: String? } -/// A withdrawal transaction. -struct TransactionWithdrawal: Decodable { - enum WithdrawalDetails { - case manual(ManualWithdrawalDetails) - case bankIntegrated(BankIntegratedWithdrawalDetails) - } - - /// The exchange that was withdrawn from. +struct WithdrawalTransactionDetails: Decodable { var exchangeBaseUrl: String - - /// The amount of the withdrawal, including fees. - var amountRaw: Amount - - /// The amount that will be added to the withdrawer's account. - var amountEffective: Amount - - /// The details of the withdrawal. var withdrawalDetails: WithdrawalDetails - - init(from decoder: Decoder) throws { - enum CodingKeys: String, CodingKey { - case exchangeBaseUrl - case amountRaw - case amountEffective - case withdrawalDetails - case type - case exchangePaytoUris - case reservePub - case confirmed - case bankConfirmationUrl - } - - let value = try decoder.container(keyedBy: CodingKeys.self) - self.exchangeBaseUrl = try value.decode(String.self, forKey: .exchangeBaseUrl) - self.amountRaw = try value.decode(Amount.self, forKey: .amountRaw) - self.amountEffective = try value.decode(Amount.self, forKey: .amountEffective) - - let detail = try value.nestedContainer(keyedBy: CodingKeys.self, forKey: .withdrawalDetails) - let detailType = try detail.decode(String.self, forKey: .type) - if detailType == "manual-transfer" { - let paytoUris = try detail.decode([String].self, forKey: .exchangePaytoUris) - let reservePub = try detail.decode(String.self, forKey: .reservePub) - let manual = ManualWithdrawalDetails(exchangePaytoUris: paytoUris, reservePub: reservePub) - self.withdrawalDetails = .manual(manual) - } else if detailType == "taler-bank-integration-api" { - let confirmed = try detail.decode(Bool.self, forKey: .confirmed) - var bankConfirmationUrl: String? = nil - if detail.contains(.bankConfirmationUrl) { - bankConfirmationUrl = try detail.decode(String.self, forKey: .bankConfirmationUrl) - } - var reservePub : String? = nil - if detail.contains(.reservePub) { - reservePub = try detail.decode(String.self, forKey: .reservePub) - } - let bankDetails = BankIntegratedWithdrawalDetails(confirmed: confirmed, reservePub: reservePub, - bankConfirmationUrl: bankConfirmationUrl) - self.withdrawalDetails = .bankIntegrated(bankDetails) - } else { - throw TransactionDecodingError.invalidStringValue - } - } } -#if DEBUG -extension TransactionWithdrawal { // for PreViews - init(url: String) { - self.exchangeBaseUrl = url - self.amountRaw = try! Amount(fromString: "Taler:5") - self.amountEffective = try! Amount(fromString: "Taler:4.8") - let bankDetails = BankIntegratedWithdrawalDetails(confirmed: true, reservePub: nil, - bankConfirmationUrl: nil) - self.withdrawalDetails = .bankIntegrated(bankDetails) - } + +struct WithdrawalTransaction { + var common: TransactionCommon + var details: WithdrawalTransactionDetails } -#endif -/// A payment transaction. -struct TransactionPayment: Codable { - /// Additional information about the payment. - // TODO - - /// An identifier for the payment. +struct PaymentTransactionDetails: Decodable { var proposalId: String - - /// The current status of the payment. - // TODO - - /// The amount that must be paid. - var amountRaw: Amount - - /// The amount that was paid. - var amountEffective: Amount + var status: String // "paid" + var totalRefundRaw: Amount + var totalRefundEffective: Amount + var refundPending: Amount? +// var refunds: [] + var info: OrderShortInfo } -/// A refund transaction. -struct TransactionRefund: Codable { - /// Identifier for the refund. +struct PaymentTransaction { + var common: TransactionCommon + var details: PaymentTransactionDetails +} + +struct RefundTransactionDetails: Decodable { var refundedTransactionId: String - - /// Additional information about the refund - // TODO - + var refundPending: Amount? /// The amount that couldn't be applied because refund permissions expired. - var amountInvalid: Amount - - /// The amount refunded by the merchant. - var amountRaw: Amount - - /// The amount paid to the wallet after fees. - var amountEffective: Amount + var amountInvalid: Amount? + var info: OrderShortInfo } -/// A tip transaction. -struct TransactionTip: Codable { - /// The current status of the tip. - // TODO - +struct RefundTransaction { + var common: TransactionCommon + var details: RefundTransactionDetails +} + +struct TipTransactionDetails: Decodable { /// The exchange that the tip will be withdrawn from var exchangeBaseUrl: String - - /// More information about the merchant sending the tip. - // TODO - - /// The raw amount of the tip without fees. - var amountRaw: Amount - - /// The amount added to the recipient's wallet. - var amountEffective: Amount } -/// A refresh transaction. -struct TransactionRefresh: Codable { +struct TipTransaction { + var common: TransactionCommon + var details: TipTransactionDetails +} + +struct RefreshTransactionDetails: Decodable { /// The exchange that the coins are refreshed with. var exchangeBaseUrl: String - - /// The raw amount to refresh. - var amountRaw: Amount - - /// The amount to be paid as fees for the refresh. - var amountEffective: Amount } -/// A wallet transaction. -struct Transaction: Decodable, Hashable { -// private let symLog = SymLogC(0) - - var type: String - var amountRaw: Amount - var amountEffective: Amount - var transactionId: String - var timestamp: Timestamp - var extendedStatus: String - var pending: Bool - var frozen: Bool +struct RefreshTransaction { + var common: TransactionCommon + var details: RefreshTransactionDetails +} - var error: AnyCodable? - var exchangeBaseUrl: String? +enum Transaction: Decodable, Hashable, Identifiable { + case withdrawal (WithdrawalTransaction) + case payment (PaymentTransaction) + case refund (RefundTransaction) + case tip (TipTransaction) + case refresh (RefreshTransaction) + init(from decoder: Decoder) throws { + let common = try TransactionCommon.init(from: decoder) + + switch (common.type) { + case WITHDRAWAL: + let details = try WithdrawalTransactionDetails.init(from: decoder) + self = .withdrawal(WithdrawalTransaction(common: common, details: details)) + case PAYMENT: + let details = try PaymentTransactionDetails.init(from: decoder) + self = .payment(PaymentTransaction(common: common, details: details)) + case REFUND: + let details = try RefundTransactionDetails.init(from: decoder) + self = .refund(RefundTransaction(common: common, details: details)) + case TIP: + let details = try TipTransactionDetails.init(from: decoder) + self = .tip(TipTransaction(common: common, details: details)) + case REFRESH: + let details = try RefreshTransactionDetails.init(from: decoder) + self = .refresh(RefreshTransaction(common: common, details: details)) + default: + let context = DecodingError.Context( + codingPath: decoder.codingPath, + debugDescription: "Invalid transaction type") + throw DecodingError.typeMismatch(Transaction.self, context) + } + } + var id: String { common().transactionId } -// enum TransactionDetail { -// case withdrawal(TransactionWithdrawal) -// } - -// var detail: TransactionDetail - -// init(from decoder: Decoder) throws { -// enum CodingKeys: String, CodingKey { -// case transactionId -// case timestamp -// case pending -// case error -// case amountRaw -// case amountEffective -// case type -// } -// -// let value = try decoder.container(keyedBy: CodingKeys.self) -// self.transactionId = try value.decode(String.self, forKey: .transactionId) -// self.timestamp = try value.decode(Timestamp.self, forKey: .timestamp) -// self.pending = try value.decode(Bool.self, forKey: .pending) -// if value.contains(.error) { -// self.error = try value.decode(AnyCodable.self, forKey: .error) -// } -// self.amountRaw = try value.decode(Amount.self, forKey: .amountRaw) -// self.amountEffective = try value.decode(Amount.self, forKey: .amountEffective) -// -// let type = try value.decode(String.self, forKey: .type) -// if type == "withdrawal" { -// let withdrawDetail = try TransactionWithdrawal.init(from: decoder) -// self.detail = .withdrawal(withdrawDetail) -// } else { -// throw TransactionDecodingError.invalidStringValue -// } -// symLog.log("\(self)") -// } - static func == (lhs: Transaction, rhs: Transaction) -> Bool { - return lhs.transactionId == rhs.transactionId + return lhs.id == rhs.id } - + func hash(into hasher: inout Hasher) { - transactionId.hash(into: &hasher) + id.hash(into: &hasher) + } + + func common() -> TransactionCommon { + switch self { + case .withdrawal(let withdrawalTransaction): + return withdrawalTransaction.common + case .payment(let paymentTransaction): + return paymentTransaction.common + case .refund(let refundTransaction): + return refundTransaction.common + case .tip(let tipTransaction): + return tipTransaction.common + case .refresh(let refreshTransaction): + return refreshTransaction.common + } + } + + func detailsToShow() -> Dictionary<String, String> { + var result: [String:String] = [:] + switch self { + case .withdrawal(let withdrawalTransaction): + result[EXCHANGEBASEURL] = withdrawalTransaction.details.exchangeBaseUrl + case .payment(let paymentTransaction): + result["status"] = paymentTransaction.details.status + case .refund(let refundTransaction): + result["summary"] = refundTransaction.details.info.summary + case .tip(let tipTransaction): + result[EXCHANGEBASEURL] = tipTransaction.details.exchangeBaseUrl + case .refresh(let refreshTransaction): + result[EXCHANGEBASEURL] = refreshTransaction.details.exchangeBaseUrl + } + return result } } + #if DEBUG extension Transaction { // for PreViews init(id: String, time: Timestamp) { - self.type = "withdrawal" - self.amountRaw = try! Amount(fromString: "Taler:5") - self.amountEffective = try! Amount(fromString: "Taler:4.8") - self.transactionId = id - self.timestamp = time - self.extendedStatus = "done" - self.pending = false - self.frozen = false - self.error = nil - self.exchangeBaseUrl = "Exchange.Demo.Taler.net" -// let withdrawDetail = TransactionWithdrawal(url: "Exchange.Demo.Taler.net") -// self.detail = .withdrawal(withdrawDetail) + let common = TransactionCommon(type: WITHDRAWAL, + amountEffective: try! Amount(fromString: "Taler:4.8"), + amountRaw: try! Amount(fromString: "Taler:5"), + transactionId: id, timestamp: time, + extendedStatus: "done", pending: false, frozen: false) + let withdrawalDetails = WithdrawalDetails(type: WithdrawalDetails.WithdrawalType.bankIntegrated, + reservePub: "Public Key of the Exchange", + confirmed: true) + let details = WithdrawalTransactionDetails(exchangeBaseUrl: "Exchange.Demo.Taler.net", + withdrawalDetails: withdrawalDetails) + self = .withdrawal(WithdrawalTransaction(common: common, details: details)) } } #endif diff --git a/TalerWallet1/Views/Transactions/TransactionDetail.swift b/TalerWallet1/Views/Transactions/TransactionDetail.swift @@ -20,10 +20,11 @@ struct TransactionDetail: View { var transaction : Transaction var body: some View { - let raw = transaction.amountRaw - let effective = transaction.amountEffective + let common = transaction.common() + let raw = common.amountRaw + let effective = common.amountEffective let fee = try! Amount.diff(raw, effective) // TODO: different currencies - let dateString = TalerDater.dateString(from: transaction.timestamp) + let dateString = TalerDater.dateString(from: common.timestamp) VStack() { Spacer() @@ -40,20 +41,20 @@ struct TransactionDetail: View { AmountView(title: "Obtained coins:", value: effective.readableDescription, color: Color("Incoming")) .padding(.bottom) - if let baseURL = transaction.exchangeBaseUrl { - VStack { - Text("From exchange:") - .font(.title3) - Text("\(baseURL.trimURL())") - .font(.title) - .fontWeight(.medium) - } - .frame(maxWidth: .infinity, alignment: .center) - } +// if let baseURL = transaction.exchangeBaseUrl { +// VStack { +// Text("From exchange:") +// .font(.title3) +// Text("\(baseURL.trimURL())") +// .font(.title) +// .fontWeight(.medium) +// } +// .frame(maxWidth: .infinity, alignment: .center) +// } Spacer() Button(role: .destructive, action: { // TODO: delete from wallet-core - print("Should delete \(transaction.transactionId)") + print("Should delete \(common.transactionId)") }, label: { HStack { Text("Delete from list" + " ") diff --git a/TalerWallet1/Views/Transactions/TransactionRow.swift b/TalerWallet1/Views/Transactions/TransactionRow.swift @@ -36,13 +36,16 @@ struct TransactionRow: View { var transaction : Transaction var body: some View { - let amount = transaction.amountEffective - let withdraw: Bool = transaction.type == "withdrawal" - let payment: Bool = transaction.type == "payment" - let refund: Bool = transaction.type == "refund" + let common = transaction.common() + let details = transaction.detailsToShow() + let keys = details.keys + let amount = common.amountEffective + let withdraw: Bool = common.type == WITHDRAWAL + let payment: Bool = common.type == PAYMENT + let refund: Bool = common.type == REFUND let incoming = withdraw || refund - let counterparty = transaction.exchangeBaseUrl - let dateString = TalerDater.dateString(from: transaction.timestamp, relative: true) +// let counterparty = transaction.exchangeBaseUrl + let dateString = TalerDater.dateString(from: common.timestamp, relative: true) HStack { Image(systemName: incoming ? "text.badge.plus" : "text.badge.minus") @@ -50,8 +53,8 @@ struct TransactionRow: View { .padding(.trailing) .font(.largeTitle) - if withdraw { - if let baseURL = counterparty { + if keys.contains(EXCHANGEBASEURL) { + if let baseURL = details[EXCHANGEBASEURL] { TransactionRowCenter(centerTop: baseURL.trimURL(), centerBottom: dateString) } } else if payment { @@ -73,7 +76,8 @@ struct TransactionRow: View { #if DEBUG struct TransactionRow_Previews: PreviewProvider { - static var transaction = Transaction(id:"some transActionID", time: Timestamp(from: 1_666_000_000_000)) + static var transaction = Transaction(id: "some transActionID", + time: Timestamp(from: 1_666_000_000_000)) static var previews: some View { TransactionRow(transaction: transaction) } diff --git a/TalerWallet1/Views/Transactions/TransactionsListView.swift b/TalerWallet1/Views/Transactions/TransactionsListView.swift @@ -53,7 +53,8 @@ extension TransactionsListView { var body: some View { let transactions = viewModel.transactions! - List(transactions, id: \.transactionId) { transaction in + List(transactions) { transaction in + let common = transaction.common() NavigationLink { TransactionDetail(transaction: transaction) } label: { @@ -61,7 +62,7 @@ extension TransactionsListView { } .swipeActions(edge: .leading, allowsFullSwipe: true) { Button { - symLog?.log("bookmarked \(transaction.transactionId)") + symLog?.log("bookmarked \(common.transactionId)") // TODO: Bookmark } label: { Label("Bookmark", systemImage: "bookmark") @@ -69,7 +70,7 @@ extension TransactionsListView { } .swipeActions(edge: .trailing, allowsFullSwipe: true) { Button(role: .destructive) { - symLog?.log("deleted \(transaction.transactionId)") + symLog?.log("deleted \(common.transactionId)") // TODO: delete from Model. SwiftUI deletes this row from view already :-) } label: { Label("Delete", systemImage: "trash")