From e6eab7dccdc4ed495242b4019bf375ab03ff35eb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Steffen=20K=C3=B6tte?= Date: Sat, 18 Sep 2021 00:08:43 -0700 Subject: [PATCH] Add first tests, some refactoring Refs #1 --- .../LedgerLookup.swift | 71 +++++-- .../MetaDataKeys.swift | 5 +- .../Wealthsimple.Transaction.swift | 2 +- .../WealthsimpleConversionError.swift | 8 +- .../WealthsimpleLedgerMapper.swift | 4 +- Tests/.swiftlint.yml | 2 + .../Extensions/XCTestCase.swift | 21 ++ .../LedgerLookupTests.swift | 180 ++++++++++++++++++ ...wiftBeanCountWealthsimpleMapperTests.swift | 2 +- 9 files changed, 269 insertions(+), 26 deletions(-) create mode 100644 Tests/.swiftlint.yml create mode 100644 Tests/SwiftBeanCountWealthsimpleMapperTests/Extensions/XCTestCase.swift create mode 100644 Tests/SwiftBeanCountWealthsimpleMapperTests/LedgerLookupTests.swift diff --git a/Sources/SwiftBeanCountWealthsimpleMapper/LedgerLookup.swift b/Sources/SwiftBeanCountWealthsimpleMapper/LedgerLookup.swift index 698057e..f847be8 100644 --- a/Sources/SwiftBeanCountWealthsimpleMapper/LedgerLookup.swift +++ b/Sources/SwiftBeanCountWealthsimpleMapper/LedgerLookup.swift @@ -9,12 +9,15 @@ import Foundation import SwiftBeanCountModel import Wealthsimple +protocol WealthsimpleAccountRepresentable { + var number: String { get } + var accountType: Wealthsimple.Account.AccountType { get } + var currency: String { get } +} + /// To lookup things in the ledger struct LedgerLookup { - /// Key used to look up assets for wealthsimple symbols in the ledger - static let symbolMetaDataKey = "wealthsimple-symbol" - /// Key used to look up accounts by keys (e.g. symbols or transactions types) in the ledger static let keyMetaDataKey = "wealthsimple-key" @@ -30,17 +33,30 @@ struct LedgerLookup { self.ledger = ledger } + /// Checks if a transaction with a certain wealthsimple id as meta data already exists in the ledger + /// The check only checks based on the wealthsimple id - not any date, amount or other property + /// - Parameter transaction: transaction to check - should have MetaDataKeys.id set as meta data + /// - Returns: if a transaction with this id is already in the ledger func doesTransactionExistInLedger(_ transaction: SwiftBeanCountModel.Transaction) -> Bool { - self.ledger.transactions.contains { - $0.metaData.metaData[MetaDataKeys.id] == transaction.metaData.metaData[MetaDataKeys.id] || - $0.metaData.metaData[MetaDataKeys.nrwtId] == transaction.metaData.metaData[MetaDataKeys.id] + guard let id = transaction.metaData.metaData[MetaDataKeys.id] else { + return false + } + return self.ledger.transactions.contains { + $0.metaData.metaData[MetaDataKeys.id] == id || + $0.metaData.metaData[MetaDataKeys.nrwtId] == id } } + /// Checks if a specific price is already in the ledger + /// - Parameter price: price to check + /// - Returns: if the price exists func doesPriceExistInLedger(_ price: SwiftBeanCountModel.Price) -> Bool { ledger.prices.contains(price) } + /// Checks if a specific balance exists in the ledger + /// - Parameter balance: balance to check + /// - Returns: if the balance already exists func doesBalanceExistInLedger(_ balance: Balance) -> Bool { guard let account = ledger.accounts.first(where: { $0.name == balance.accountName }) else { return false @@ -67,16 +83,21 @@ struct LedgerLookup { } } - func ledgerSymbol(for asset: Asset) throws -> String { + func commoditySymbol(for asset: Asset) throws -> CommoditySymbol { if asset.type == .currency { return asset.symbol } else { - return try ledgerSymbol(for: asset.symbol) + return try commoditySymbol(for: asset.symbol) } } - func ledgerSymbol(for assetSymbol: String) throws -> String { - var commodity = ledger.commodities.first { $0.metaData[Self.symbolMetaDataKey] == assetSymbol } + /// Finds the right CommoditySymbol from the ledger to use for a given asset symbol + /// The user can specify this via Self.symbolMetaDataKey, otherwise it try to use the commodity with the same symbol + /// - Parameter assetSymbol: asset symbol to find the commodity for + /// - Throws: WealthsimpleConversionError if the commodity cannot be found in the ledger + /// - Returns: CommoditySymbol + func commoditySymbol(for assetSymbol: String) throws -> CommoditySymbol { + var commodity = ledger.commodities.first { $0.metaData[MetaDataKeys.commoditySymbol] == assetSymbol } if commodity == nil { commodity = ledger.commodities.first { $0.symbol == assetSymbol } } @@ -87,7 +108,11 @@ struct LedgerLookup { } /// Returns account name to use for a certain type of posting - not including the Wealthsimple accounts themselves - func ledgerAccountName(for account: Wealthsimple.Account, ofType type: [SwiftBeanCountModel.AccountType], symbol assetSymbol: String? = nil) throws -> AccountName { + func ledgerAccountName( + for account: WealthsimpleAccountRepresentable, + ofType type: [SwiftBeanCountModel.AccountType], + symbol assetSymbol: String? = nil + ) throws -> AccountName { let symbol = assetSymbol ?? account.currency let accountType = account.accountType.rawValue let account = ledger.accounts.first { @@ -102,7 +127,12 @@ struct LedgerLookup { } /// Returns account name of matching the Wealthsimple account in the ledger - func ledgerAccountName(of account: Wealthsimple.Account, symbol assetSymbol: String? = nil) throws -> AccountName { + /// - Parameters: + /// - account: Account to get the name for + /// - assetSymbol: Assets symbol in the account. If not specified cash account will be returned + /// - Throws: WealthsimpleConversionError if the account cannot be found + /// - Returns: Name of the matching account + func ledgerAccountName(of account: WealthsimpleAccountRepresentable, symbol assetSymbol: String? = nil) throws -> AccountName { let baseAccount = ledger.accounts.first { $0.metaData[MetaDataKeys.importerType] == MetaData.importerType && $0.metaData[MetaDataKeys.number] == account.number @@ -111,9 +141,9 @@ struct LedgerLookup { throw WealthsimpleConversionError.missingWealthsimpleAccount(account.number) } if let symbol = assetSymbol { - let name = "\(accountName.fullName.split(separator: ":").dropLast(1).joined(separator: ":")):\(try ledgerSymbol(for: symbol))" + let name = "\(accountName.fullName.split(separator: ":").dropLast(1).joined(separator: ":")):\(try commoditySymbol(for: symbol))" guard let result = try? AccountName(name) else { - throw WealthsimpleConversionError.missingWealthsimpleAccount(account.number) + throw WealthsimpleConversionError.invalidCommoditySymbol(symbol) } return result } else { @@ -121,10 +151,13 @@ struct LedgerLookup { } } - func ledgerAccountCommoditySymbol(of account: AccountName) -> String? { - let account = ledger.accounts.first { - $0.name == account - } - return account?.commoditySymbol + /// Get the CommoditySymbol of a specified account in the ledger + /// - Parameter account: name of the account to check + /// - Returns:CommoditySymbol or nil if either the account could not be found or did not have a commodity assigned + func ledgerAccountCommoditySymbol(of account: AccountName) -> CommoditySymbol? { + ledger.accounts.first { $0.name == account }?.commoditySymbol } } + +extension Wealthsimple.Account: WealthsimpleAccountRepresentable { +} diff --git a/Sources/SwiftBeanCountWealthsimpleMapper/MetaDataKeys.swift b/Sources/SwiftBeanCountWealthsimpleMapper/MetaDataKeys.swift index 97dd7da..e0d3c70 100644 --- a/Sources/SwiftBeanCountWealthsimpleMapper/MetaDataKeys.swift +++ b/Sources/SwiftBeanCountWealthsimpleMapper/MetaDataKeys.swift @@ -7,7 +7,7 @@ /// Data in meta data used in the ledger enum MetaData { - /// + /// name of the importer static let importerType = "wealthsimple" } @@ -35,4 +35,7 @@ enum MetaDataKeys { /// Key used for wealthsimple account numbers in the ledger static let number = "number" + /// Key used to look up commodities for wealthsimple symbols in the ledger + static let commoditySymbol = "wealthsimple-symbol" + } diff --git a/Sources/SwiftBeanCountWealthsimpleMapper/Wealthsimple.Transaction.swift b/Sources/SwiftBeanCountWealthsimpleMapper/Wealthsimple.Transaction.swift index fab29ea..1f3b2bc 100644 --- a/Sources/SwiftBeanCountWealthsimpleMapper/Wealthsimple.Transaction.swift +++ b/Sources/SwiftBeanCountWealthsimpleMapper/Wealthsimple.Transaction.swift @@ -30,7 +30,7 @@ extension Wealthsimple.Transaction { } func quantitySymbol(lookup: LedgerLookup) throws -> String { - cashTypes.contains(transactionType) ? symbol : try lookup.ledgerSymbol(for: symbol) + cashTypes.contains(transactionType) ? symbol : try lookup.commoditySymbol(for: symbol) } func quantityAmount(lookup: LedgerLookup) throws -> Amount { diff --git a/Sources/SwiftBeanCountWealthsimpleMapper/WealthsimpleConversionError.swift b/Sources/SwiftBeanCountWealthsimpleMapper/WealthsimpleConversionError.swift index cbe4079..24890b2 100644 --- a/Sources/SwiftBeanCountWealthsimpleMapper/WealthsimpleConversionError.swift +++ b/Sources/SwiftBeanCountWealthsimpleMapper/WealthsimpleConversionError.swift @@ -8,7 +8,7 @@ import Foundation /// Errors which can happen when transforming downloaded wealthsimple data into SwiftBeanCountModel types -public enum WealthsimpleConversionError: Error { +public enum WealthsimpleConversionError: Error, Equatable { /// a commodity was not found in the ledger case missingCommodity(String) /// an account was not found in the ledger @@ -22,13 +22,15 @@ public enum WealthsimpleConversionError: Error { /// the account of the postion or transaction is not contained in the account property /// Did you forget to set it to the downloaded accounts before attempting mapping? case accountNotFound(String) + /// A commodity symbol was used which cannot be used as account name string + case invalidCommoditySymbol(String) } extension WealthsimpleConversionError: LocalizedError { public var errorDescription: String? { switch self { case let .missingCommodity(symbol): - return "The Commodity \(symbol) was not found in your ledger. Please make sure you add the metadata \"\(LedgerLookup.symbolMetaDataKey): \"\(symbol)\"\" to it." + return "The Commodity \(symbol) was not found in your ledger. Please make sure you add the metadata \"\(MetaDataKeys.commoditySymbol): \"\(symbol)\"\" to it." case let .missingAccount(key, category, accountType): return """ The \(category) account for account type \(accountType) and key \(key) was not found in your ledger. \ @@ -45,6 +47,8 @@ extension WealthsimpleConversionError: LocalizedError { return "Wealthsimple returned an unexpected description for a transaction: \(string)" case let .accountNotFound(accountId): return "Wealthsimple returned an element from an account with id \(accountId) which was not found." + case let .invalidCommoditySymbol(symbol): + return "Could not generate account for commodity \(symbol). For the mapping to work commodity symbols must only contain charaters allowed in account names." } } } diff --git a/Sources/SwiftBeanCountWealthsimpleMapper/WealthsimpleLedgerMapper.swift b/Sources/SwiftBeanCountWealthsimpleMapper/WealthsimpleLedgerMapper.swift index 063a0ba..08ec457 100644 --- a/Sources/SwiftBeanCountWealthsimpleMapper/WealthsimpleLedgerMapper.swift +++ b/Sources/SwiftBeanCountWealthsimpleMapper/WealthsimpleLedgerMapper.swift @@ -103,9 +103,9 @@ public struct WealthsimpleLedgerMapper { var balances = [Balance]() try positions.forEach { let price = Self.amount(for: $0.priceAmount, in: $0.priceCurrency) - let balanceAmount = Self.amount(for: $0.quantity, in: try lookup.ledgerSymbol(for: $0.asset)) + let balanceAmount = Self.amount(for: $0.quantity, in: try lookup.commoditySymbol(for: $0.asset)) if $0.asset.type != .currency { - let price = try Price(date: $0.positionDate, commoditySymbol: try lookup.ledgerSymbol(for: $0.asset), amount: price) + let price = try Price(date: $0.positionDate, commoditySymbol: try lookup.commoditySymbol(for: $0.asset), amount: price) if !lookup.doesPriceExistInLedger(price) { prices.append(price) } diff --git a/Tests/.swiftlint.yml b/Tests/.swiftlint.yml new file mode 100644 index 0000000..0373cf4 --- /dev/null +++ b/Tests/.swiftlint.yml @@ -0,0 +1,2 @@ +disabled_rules: + - force_try diff --git a/Tests/SwiftBeanCountWealthsimpleMapperTests/Extensions/XCTestCase.swift b/Tests/SwiftBeanCountWealthsimpleMapperTests/Extensions/XCTestCase.swift new file mode 100644 index 0000000..a4c7a0b --- /dev/null +++ b/Tests/SwiftBeanCountWealthsimpleMapperTests/Extensions/XCTestCase.swift @@ -0,0 +1,21 @@ +import Foundation +import XCTest + +extension XCTestCase { + + func assert(_ expression: @autoclosure () throws -> T, throws expectedError: E, in file: StaticString = #file, line: UInt = #line) { + var caughtError: Error? + + XCTAssertThrowsError(try expression(), file: file, line: line) { + caughtError = $0 + } + + guard let error = caughtError as? E else { + XCTFail("Unexpected error type, got \(type(of: caughtError!)) instead of \(E.self)", file: file, line: line) + return + } + + XCTAssertEqual(error, expectedError, file: file, line: line) + } + +} diff --git a/Tests/SwiftBeanCountWealthsimpleMapperTests/LedgerLookupTests.swift b/Tests/SwiftBeanCountWealthsimpleMapperTests/LedgerLookupTests.swift new file mode 100644 index 0000000..3f893a5 --- /dev/null +++ b/Tests/SwiftBeanCountWealthsimpleMapperTests/LedgerLookupTests.swift @@ -0,0 +1,180 @@ +import SwiftBeanCountModel +@testable import SwiftBeanCountWealthsimpleMapper +import Wealthsimple +import XCTest + +struct TestAccount: WealthsimpleAccountRepresentable { + let number: String + let accountType: Wealthsimple.Account.AccountType + let currency: String +} + +final class LedgerLookupTests: XCTestCase { + + func testLedgerAccountCommoditySymbol() { + let name1 = try! AccountName("Assets:Test") + let name2 = try! AccountName("Assets:Test1") + let symbol = "CAD" + let ledger = Ledger() + var ledgerLookup = LedgerLookup(ledger) + + // account does not exist + XCTAssertNil(ledgerLookup.ledgerAccountCommoditySymbol(of: name1)) + + // account does not have a commodity + try! ledger.add(Account(name: name1)) + ledgerLookup = LedgerLookup(ledger) + XCTAssertNil(ledgerLookup.ledgerAccountCommoditySymbol(of: name1)) + + // account has a commodity + try! ledger.add(Account(name: name2, commoditySymbol: symbol)) + ledgerLookup = LedgerLookup(ledger) + XCTAssertNil(ledgerLookup.ledgerAccountCommoditySymbol(of: name1)) + XCTAssertEqual(ledgerLookup.ledgerAccountCommoditySymbol(of: name2), symbol) + + } + + func testLedgerAccountNameOf() { + let name1 = try! AccountName("Assets:Test") + let account1 = TestAccount(number: "abc", accountType: .nonRegistered, currency: "CAD") + let ledger = Ledger() + var ledgerLookup = LedgerLookup(ledger) + + // not found + assert( + try ledgerLookup.ledgerAccountName(of: account1), + throws: WealthsimpleConversionError.missingWealthsimpleAccount("abc") + ) + + // base account + try! ledger.add(Commodity(symbol: "XGRO")) + try! ledger.add(Account(name: name1, metaData: ["importer-type": "wealthsimple", "number": "abc"])) + ledgerLookup = LedgerLookup(ledger) + XCTAssertEqual(try! ledgerLookup.ledgerAccountName(of: account1), name1) + + // commodity account + XCTAssertEqual(try! ledgerLookup.ledgerAccountName(of: account1, symbol: "XGRO"), try! AccountName("Assets:XGRO")) + + // invalid commodity symbol + try! ledger.add(Commodity(symbol: "XGRO:")) + assert( + try ledgerLookup.ledgerAccountName(of: account1, symbol: "XGRO:"), + throws: WealthsimpleConversionError.invalidCommoditySymbol("XGRO:") + ) + } + + func testDoesTransactionExistInLedger() { + let ledger = Ledger() + var metaData = TransactionMetaData(date: Date(), metaData: [MetaDataKeys.id: "abc"]) + var transaction = Transaction(metaData: metaData, postings: []) + ledger.add(transaction) + var ledgerLookup = LedgerLookup(ledger) + + // same transaction + XCTAssert(ledgerLookup.doesTransactionExistInLedger(transaction)) + + // different date + metaData = TransactionMetaData(date: Date(timeIntervalSinceReferenceDate: 0), metaData: [MetaDataKeys.id: "abc"]) + transaction = Transaction(metaData: metaData, postings: []) + XCTAssert(ledgerLookup.doesTransactionExistInLedger(transaction)) + + // nrwt id + metaData = TransactionMetaData(date: Date(), metaData: [MetaDataKeys.nrwtId: "abcd"]) + transaction = Transaction(metaData: metaData, postings: []) + ledger.add(transaction) + ledgerLookup = LedgerLookup(ledger) + metaData = TransactionMetaData(date: Date(), metaData: [MetaDataKeys.id: "abcd"]) + transaction = Transaction(metaData: metaData, postings: []) + XCTAssert(ledgerLookup.doesTransactionExistInLedger(transaction)) + + // different id + metaData = TransactionMetaData(date: Date(), metaData: [MetaDataKeys.id: "abc1"]) + transaction = Transaction(metaData: metaData, postings: []) + XCTAssertFalse(ledgerLookup.doesTransactionExistInLedger(transaction)) + + // no id + metaData = TransactionMetaData(date: Date()) + transaction = Transaction(metaData: metaData, postings: []) + XCTAssertFalse(ledgerLookup.doesTransactionExistInLedger(transaction)) + } + + func testDoesPriceExistInLedger() { + let ledger = Ledger() + let date = Date() + var price = try! Price(date: date, commoditySymbol: "CAD", amount: Amount(number: Decimal(1), commoditySymbol: "EUR")) + try! ledger.add(price) + let ledgerLookup = LedgerLookup(ledger) + + // same price + XCTAssert(ledgerLookup.doesPriceExistInLedger(price)) + + // different price object with same properties + price = try! Price(date: date, commoditySymbol: "CAD", amount: Amount(number: Decimal(1), commoditySymbol: "EUR")) + XCTAssert(ledgerLookup.doesPriceExistInLedger(price)) + + // different date + price = try! Price(date: Date(timeIntervalSinceReferenceDate: 0), commoditySymbol: "CAD", amount: Amount(number: Decimal(1), commoditySymbol: "EUR")) + XCTAssertFalse(ledgerLookup.doesPriceExistInLedger(price)) + + // different commodity + price = try! Price(date: date, commoditySymbol: "CAD", amount: Amount(number: Decimal(1), commoditySymbol: "USD")) + XCTAssertFalse(ledgerLookup.doesPriceExistInLedger(price)) + } + + func testDoesBalanceExistInLedger() { + let ledger = Ledger() + let date = Date() + let name = try! AccountName("Assets:TEST") + var balance = Balance(date: date, accountName: name, amount: Amount(number: Decimal(1), commoditySymbol: "USD")) + ledger.add(balance) + let ledgerLookup = LedgerLookup(ledger) + + // same balance + XCTAssert(ledgerLookup.doesBalanceExistInLedger(balance)) + + // different balance object with same properties + balance = Balance(date: date, accountName: name, amount: Amount(number: Decimal(1), commoditySymbol: "USD")) + XCTAssert(ledgerLookup.doesBalanceExistInLedger(balance)) + + // different date + balance = Balance(date: Date(timeIntervalSinceReferenceDate: 0), accountName: name, amount: Amount(number: Decimal(1), commoditySymbol: "USD")) + XCTAssertFalse(ledgerLookup.doesBalanceExistInLedger(balance)) + + // different commodity + balance = Balance(date: date, accountName: name, amount: Amount(number: Decimal(1), commoditySymbol: "EUR")) + XCTAssertFalse(ledgerLookup.doesBalanceExistInLedger(balance)) + + // different account + balance = Balance(date: date, accountName: try! AccountName("Assets:TEST1"), amount: Amount(number: Decimal(1), commoditySymbol: "USD")) + XCTAssertFalse(ledgerLookup.doesBalanceExistInLedger(balance)) + } + + func testCommoditySymbolForAssetSymbol() { + let ledger = Ledger() + var commodity = Commodity(symbol: "EUR") + try! ledger.add(commodity) + var ledgerLookup = LedgerLookup(ledger) + + // not existing + assert( + try ledgerLookup.commoditySymbol(for: "USD"), + throws: WealthsimpleConversionError.missingCommodity("USD") + ) + + // fallback + XCTAssertEqual(try! ledgerLookup.commoditySymbol(for: "EUR"), "EUR") + + // mapping exists + commodity = Commodity(symbol: "USDABC", metaData: [MetaDataKeys.commoditySymbol: "USD"]) + try! ledger.add(commodity) + ledgerLookup = LedgerLookup(ledger) + XCTAssertEqual(try! ledgerLookup.commoditySymbol(for: "USD"), "USDABC") + + // mapping + fallback exists + commodity = Commodity(symbol: "USD") + try! ledger.add(commodity) + ledgerLookup = LedgerLookup(ledger) + XCTAssertEqual(try! ledgerLookup.commoditySymbol(for: "USD"), "USDABC") + } + +} diff --git a/Tests/SwiftBeanCountWealthsimpleMapperTests/SwiftBeanCountWealthsimpleMapperTests.swift b/Tests/SwiftBeanCountWealthsimpleMapperTests/SwiftBeanCountWealthsimpleMapperTests.swift index 52887a4..8c07868 100644 --- a/Tests/SwiftBeanCountWealthsimpleMapperTests/SwiftBeanCountWealthsimpleMapperTests.swift +++ b/Tests/SwiftBeanCountWealthsimpleMapperTests/SwiftBeanCountWealthsimpleMapperTests.swift @@ -1,4 +1,4 @@ -@testable import Wealthsimple +@testable import SwiftBeanCountWealthsimpleMapper import XCTest final class SwiftBeanCountWealthsimpleMapperTests: XCTestCase {