diff --git a/README.md b/README.md index 50ca9e5..111b272 100644 --- a/README.md +++ b/README.md @@ -23,21 +23,29 @@ If the commodity in your ledger differs from the symbol used by Wealthsimple, si ### Accounts -For Wealthsimple accounts themselves, you need to add this metadata: `importer-type: "wealthsimple"` and `number: "XXX"`. If the account can hold more than one commodity (all accounts except chequing and saving), it needs to follow this structure: `Assets:X:Y:Z:CashAccountName`, `Assets:X:Y:Z:CommodityName`, `Assets:X:Y:Z:OtherCommodityName`. The name of the cash account does not matter, but all other account must end with the commodity symbol (see above). Only add the `importer-type` and `number` to the cash account. - -For account used for transaction to and from your Wealthsimple accounts you need to add two meta data entries: -* First is the account type (`wealthsimple-account-type`), you can look up the possible values [here](https://github.com/Nef10/WealthsimpleDownloader/blob/main/Sources/Wealthsimple/Account.swift#L37) -* Second is a key (`wealthsimple-key`), for example: - * For dividend income accounts this is the symbol as of the stock or ETF - * For the assset account you are going to contribute from, use `contribution` - * For the assset account you are going to deposit from, use `deposit` - * Use `fee` on an expense account to track the wealthsimple fees - * Use `nonResidentWithholdingTax` on an expense account for the tax - * In case some transaction does not balance, we will look for an expense account with `rounding` - * In case you get a refund, add `refund` to an income account - * If you want to track contribution room, use `contribution-room` on an asset and expense account (optional) - -Both keys and types can be space separated in case you have multiple Wealthsimple accounts and for example want to combine the fees into one expense account, or you contribute from the same account. +For Wealthsimple accounts themselves, you need to add this metadata: `importer-type: "wealthsimple"` and `number: "XXX"`. If the account can hold more than one commodity (all accounts except chequing and saving), it needs to follow this structure: `Assets:X:Y:Z:CashAccountName`, `Assets:X:Y:Z:CommodityName`, `Assets:X:Y:Z:OtherCommodityName`. The name of the cash account does not matter, but all other account must end with the commodity symbol (see above). Add the `importer-type` and `number` only to the cash account. + +For accounts used in transactions to and from your Wealthsimple accounts you need to provide meta data as well. These is in the form of `wealthsimple-key: "accountNumber1 accountNumber2"`. The account number is the same as above, and you can specify one or multiple per key. As keys use these values: + +* For dividend income accounts `wealthsimple-dividend-COMMODITYSYMBOL`, e.g. `wealthsimple-dividend-XGRO` +* For the assset account you are using to contribute to registered accounts from, use `wealthsimple-contribution` +* For the assset account you are using to deposit to non-registered accounts from, use `wealthsimple-deposit` +* Use `wealthsimple-fee` on an expense account to track the wealthsimple fees +* Use `wealthsimple-non-resident-withholding-tax` on an expense account for non resident withholding tax +* In case some transaction does not balance within your ledger, an expense account with `wealthsimple-rounding` will get the difference +* If you want to track contribution room, use `wealthsimple-contribution-room` on an asset and expense account (optional) +* Other values for transaction types you might incur are: + * `wealthsimple-reimbursement` + * `wealthsimple-interest` + * `wealthsimple-withdrawal` + * `wealthsimple-payment-transfer-in` + * `wealthsimple-payment-transfer-out` + * `wealthsimple-transfer-in` + * `wealthsimple-transfer-out` + * `wealthsimple-referral-bonus` + * `wealthsimple-giveaway-bonus` + * `wealthsimple-refund` + * `wealthsimple-payment-spend`
Full Example @@ -54,40 +62,25 @@ Both keys and types can be space separated in case you have multiple Wealthsimpl 2020-07-31 open Assets:Investment:Wealthsimple:TFSA:XGRO XGRO 2020-07-31 open Income:Capital:Dividend:ACWV USD - wealthsimple-account-type: "ca_tfsa" - wealthsimple-key: "ACWV" + wealthsimple-dividend-ACWV: "A001 B002" 2020-07-31 open Assets:Checking:Bank CAD - wealthsimple-account-type: "ca_tfsa" - wealthsimple-key: "contribution" + wealthsimple-contribution: "A001 B002" -2020-07-31 open Assets:Investment:OtherCompany:TFSA - wealthsimple-account-type: "ca_tfsa" - wealthsimple-key: "deposit" +2020-07-31 open Expenses:FinancialInstitutions:Investment:NonRegistered:Fees + wealthsimple-fee: "A001" -2020-07-31 open Expenses:FinancialInstitutions:Investment:Fees - wealthsimple-account-type: "ca_tfsa" - wealthsimple-key: "fee" +2020-07-31 open Expenses:FinancialInstitutions:Investment:Registered:Fees + wealthsimple-fee: "B002" 2020-07-31 open Expenses:Tax:NRWT - wealthsimple-account-type: "ca_tfsa" - wealthsimple-key: "non resident withholding tax" - -2020-07-31 open Expenses:Rounding - wealthsimple-account-type: "ca_tfsa" - wealthsimple-key: "rounding" - -2020-07-31 open Income:FinancialInstitutions - wealthsimple-account-type: "ca_tfsa" - wealthsimple-key: "refund" + wealthsimple-non-resident-withholding-tax: "A001 B002" 2020-07-31 open Assets:TFSAContributionRoom TFSA.ROOM - wealthsimple-account-type: "ca_tfsa" - wealthsimple-key: "contribution-room" + wealthsimple-contribution-room: "B002" 2020-07-31 open Expenses:TFSAContributionRoom TFSA.ROOM - wealthsimple-account-type: "ca_tfsa" - wealthsimple-key: "contribution-room" + wealthsimple-contribution-room: "B002" ````
diff --git a/Sources/SwiftBeanCountWealthsimpleMapper/Extensions/String+KebabCase.swift b/Sources/SwiftBeanCountWealthsimpleMapper/Extensions/String+KebabCase.swift new file mode 100644 index 0000000..b3a883c --- /dev/null +++ b/Sources/SwiftBeanCountWealthsimpleMapper/Extensions/String+KebabCase.swift @@ -0,0 +1,13 @@ +import Foundation + +/// extension to convert strings from camelCase to kebab-case +extension String { + + func camelCaseToKebabCase() -> String { + ["([A-Z]+)([A-Z][a-z]|[0-9])", "([a-z])([A-Z]|[0-9])", "([0-9])([A-Z])"] + .map { try! NSRegularExpression(pattern: $0, options: []) } // swiftlint:disable:this force_try + .reduce(self) { $1.stringByReplacingMatches(in: $0, range: NSRange($0.startIndex..., in: $0), withTemplate: "$1-$2") } + .lowercased() + } + +} diff --git a/Sources/SwiftBeanCountWealthsimpleMapper/LedgerLookup.swift b/Sources/SwiftBeanCountWealthsimpleMapper/LedgerLookup.swift index 384cfd8..2f5af04 100644 --- a/Sources/SwiftBeanCountWealthsimpleMapper/LedgerLookup.swift +++ b/Sources/SwiftBeanCountWealthsimpleMapper/LedgerLookup.swift @@ -9,10 +9,8 @@ import Foundation import SwiftBeanCountModel import Wealthsimple -protocol WealthsimpleAccountRepresentable { +protocol AccountNumberProvider { var number: String { get } - var accountType: Wealthsimple.Account.AccountType { get } - var currency: String { get } } enum AccoutLookupType { @@ -25,12 +23,6 @@ enum AccoutLookupType { /// To lookup things in the ledger struct LedgerLookup { - /// Key used to look up accounts by keys (e.g. symbols or transactions types) in the ledger - static let keyMetaDataKey = "wealthsimple-key" - - /// Key used to look up accounts by type in the ledger - static let accountTypeMetaDataKey = "wealthsimple-account-type" - /// Ledger to look up accounts, commodities or duplicate entries in private let ledger: Ledger @@ -90,14 +82,6 @@ struct LedgerLookup { } } - func commoditySymbol(for asset: Asset) throws -> CommoditySymbol { - if asset.type == .currency { - return asset.symbol - } else { - return try commoditySymbol(for: asset.symbol) - } - } - /// 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 @@ -115,31 +99,32 @@ struct LedgerLookup { } /// Returns account name to use for a certain type of posting - not including the Wealthsimple accounts themselves + /// - Parameters: + /// - type: AccoutLookupType to specify for which transaction type + /// - account: WealthsimpleAccount to find the specific account for + /// - accountType: Ehich account type is required, e.g. asset, expenses, income, ... + /// - Throws: WealthsimpleConversionError if the account could not be found + /// - Returns: AccountName to use func ledgerAccountName( for type: AccoutLookupType, - in account: WealthsimpleAccountRepresentable, - ofType accountType: [SwiftBeanCountModel.AccountType] + in account: AccountNumberProvider, + ofType accountTypes: [SwiftBeanCountModel.AccountType] ) throws -> AccountName { - let symbol: String + let key: String switch type { case let .transactionType(transactionType): - symbol = transactionType.rawValue + key = "\(MetaDataKeys.prefix)\("\(transactionType)".camelCaseToKebabCase())" case let .dividend(dividendSymbol): - symbol = dividendSymbol + key = "\(MetaDataKeys.dividendPrefix)\(dividendSymbol)" case .contributionRoom: - symbol = "contribution-room" + key = MetaDataKeys.contributionRoom case .rounding: - symbol = "rounding" + key = MetaDataKeys.rounding } - let resultAccount = ledger.accounts.first { - accountType.contains($0.name.accountType) && - ($0.metaData[Self.keyMetaDataKey]?.contains(symbol) ?? false) && - ($0.metaData[Self.accountTypeMetaDataKey]?.contains(account.accountType.rawValue) ?? false) + guard let name = ledger.accounts.first(where: { accountTypes.contains($0.name.accountType) && $0.metaData[key]?.contains(account.number) ?? false })?.name else { + throw WealthsimpleConversionError.missingAccount(key, account.number, accountTypes.map { $0.rawValue }.joined(separator: ", or ")) } - guard let accountName = resultAccount?.name else { - throw WealthsimpleConversionError.missingAccount(symbol, accountType.map { $0.rawValue }.joined(separator: ", or "), account.accountType.rawValue) - } - return accountName + return name } /// Returns account name of matching the Wealthsimple account in the ledger @@ -148,7 +133,7 @@ struct LedgerLookup { /// - 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 { + func ledgerAccountName(of account: AccountNumberProvider, symbol assetSymbol: String? = nil) throws -> AccountName { let baseAccount = ledger.accounts.first { $0.metaData[MetaDataKeys.importerType] == MetaData.importerType && $0.metaData[MetaDataKeys.number] == account.number @@ -174,5 +159,5 @@ struct LedgerLookup { } } -extension Wealthsimple.Account: WealthsimpleAccountRepresentable { +extension Wealthsimple.Account: AccountNumberProvider { } diff --git a/Sources/SwiftBeanCountWealthsimpleMapper/MetaDataKeys.swift b/Sources/SwiftBeanCountWealthsimpleMapper/MetaDataKeys.swift index e0d3c70..0cd73d6 100644 --- a/Sources/SwiftBeanCountWealthsimpleMapper/MetaDataKeys.swift +++ b/Sources/SwiftBeanCountWealthsimpleMapper/MetaDataKeys.swift @@ -14,11 +14,17 @@ enum MetaData { /// Keys for meta data used in the ledger enum MetaDataKeys { + /// Prefix used for several keys + static let prefix = "wealthsimple-" + + /// Key prefix used to look up dividend accounts + static let dividendPrefix = "wealthsimple-dividend-" + /// Key used to save and lookup the wealthsimple transaction id of transactions in the meta data - static let id = "wealthsimple-id" + static let id = "\(prefix)id" /// Key used to save and the wealthsimple transaction id of a merged nrwt transactions in the meta data - static let nrwtId = "wealthsimple-id-nrwt" + static let nrwtId = "\(prefix)id-nrwt" /// Key used to save the record date of a dividend on dividend transactions static let dividendRecordDate = "record-date" @@ -36,6 +42,12 @@ enum MetaDataKeys { static let number = "number" /// Key used to look up commodities for wealthsimple symbols in the ledger - static let commoditySymbol = "wealthsimple-symbol" + static let commoditySymbol = "\(prefix)symbol" + + /// Key used to look up accounts tracking the contribution room + static let contributionRoom = "\(prefix)contribution-room" + + /// Key used to look up accounts tracking rounding errors + static let rounding = "\(prefix)rounding" } diff --git a/Sources/SwiftBeanCountWealthsimpleMapper/WealthsimpleConversionError.swift b/Sources/SwiftBeanCountWealthsimpleMapper/WealthsimpleConversionError.swift index 24890b2..73044a5 100644 --- a/Sources/SwiftBeanCountWealthsimpleMapper/WealthsimpleConversionError.swift +++ b/Sources/SwiftBeanCountWealthsimpleMapper/WealthsimpleConversionError.swift @@ -31,10 +31,10 @@ extension WealthsimpleConversionError: LocalizedError { switch self { case let .missingCommodity(symbol): 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): + case let .missingAccount(key, number, category): return """ - The \(category) account for account type \(accountType) and key \(key) was not found in your ledger. \ - Please make sure you add the metadata \"\(LedgerLookup.keyMetaDataKey): \"\(key)\" \(LedgerLookup.accountTypeMetaDataKey): \"\(accountType)\"\" to it. + The \(category) account for account number \(number) and key \(key) was not found in your ledger. \ + Please make sure you add the metadata \"\(key): \"\(number)\"" to it. """ case let .missingWealthsimpleAccount(number): return """ diff --git a/Sources/SwiftBeanCountWealthsimpleMapper/WealthsimpleLedgerMapper.swift b/Sources/SwiftBeanCountWealthsimpleMapper/WealthsimpleLedgerMapper.swift index ba1df5f..5c21405 100644 --- a/Sources/SwiftBeanCountWealthsimpleMapper/WealthsimpleLedgerMapper.swift +++ b/Sources/SwiftBeanCountWealthsimpleMapper/WealthsimpleLedgerMapper.swift @@ -86,9 +86,9 @@ public struct WealthsimpleLedgerMapper { var balances = [Balance]() try positions.forEach { let price = Amount(for: $0.priceAmount, in: $0.priceCurrency) - let balanceAmount = Amount(for: $0.quantity, in: try lookup.commoditySymbol(for: $0.asset)) + let balanceAmount = Amount(for: $0.quantity, in: try lookup.commoditySymbol(for: $0.asset.symbol)) if $0.asset.type != .currency { - let price = try Price(date: $0.positionDate, commoditySymbol: try lookup.commoditySymbol(for: $0.asset), amount: price) + let price = try Price(date: $0.positionDate, commoditySymbol: try lookup.commoditySymbol(for: $0.asset.symbol), amount: price) if !lookup.doesPriceExistInLedger(price) { prices.append(price) } diff --git a/Tests/SwiftBeanCountWealthsimpleMapperTests/Extensions/StringKebabCaseTests.swift b/Tests/SwiftBeanCountWealthsimpleMapperTests/Extensions/StringKebabCaseTests.swift new file mode 100644 index 0000000..8f310eb --- /dev/null +++ b/Tests/SwiftBeanCountWealthsimpleMapperTests/Extensions/StringKebabCaseTests.swift @@ -0,0 +1,18 @@ +@testable import SwiftBeanCountWealthsimpleMapper +import XCTest + +final class StringKebabCaseTests: XCTestCase { + + func testCamelCaseToKebabCase() { + XCTAssertEqual("TEST123".camelCaseToKebabCase(), "test-123") + XCTAssertEqual("TEST1Test".camelCaseToKebabCase(), "test-1-test") + XCTAssertEqual("EURTest".camelCaseToKebabCase(), "eur-test") + XCTAssertEqual("ThisIsATest".camelCaseToKebabCase(), "this-is-a-test") + XCTAssertEqual("1234ThisIsATest".camelCaseToKebabCase(), "1234-this-is-a-test") + XCTAssertEqual("test123".camelCaseToKebabCase(), "test-123") + XCTAssertEqual("test".camelCaseToKebabCase(), "test") + XCTAssertEqual("123".camelCaseToKebabCase(), "123") + XCTAssertEqual("".camelCaseToKebabCase(), "") + } + +} diff --git a/Tests/SwiftBeanCountWealthsimpleMapperTests/LedgerLookupTests.swift b/Tests/SwiftBeanCountWealthsimpleMapperTests/LedgerLookupTests.swift index 3f893a5..7611fa3 100644 --- a/Tests/SwiftBeanCountWealthsimpleMapperTests/LedgerLookupTests.swift +++ b/Tests/SwiftBeanCountWealthsimpleMapperTests/LedgerLookupTests.swift @@ -3,10 +3,8 @@ import SwiftBeanCountModel import Wealthsimple import XCTest -struct TestAccount: WealthsimpleAccountRepresentable { +struct TestAccount: AccountNumberProvider { let number: String - let accountType: Wealthsimple.Account.AccountType - let currency: String } final class LedgerLookupTests: XCTestCase { @@ -36,13 +34,13 @@ final class LedgerLookupTests: XCTestCase { func testLedgerAccountNameOf() { let name1 = try! AccountName("Assets:Test") - let account1 = TestAccount(number: "abc", accountType: .nonRegistered, currency: "CAD") + let account = TestAccount(number: "abc") let ledger = Ledger() var ledgerLookup = LedgerLookup(ledger) // not found assert( - try ledgerLookup.ledgerAccountName(of: account1), + try ledgerLookup.ledgerAccountName(of: account), throws: WealthsimpleConversionError.missingWealthsimpleAccount("abc") ) @@ -50,19 +48,66 @@ final class LedgerLookupTests: XCTestCase { 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) + XCTAssertEqual(try! ledgerLookup.ledgerAccountName(of: account), name1) // commodity account - XCTAssertEqual(try! ledgerLookup.ledgerAccountName(of: account1, symbol: "XGRO"), try! AccountName("Assets:XGRO")) + XCTAssertEqual(try! ledgerLookup.ledgerAccountName(of: account, symbol: "XGRO"), try! AccountName("Assets:XGRO")) // invalid commodity symbol try! ledger.add(Commodity(symbol: "XGRO:")) assert( - try ledgerLookup.ledgerAccountName(of: account1, symbol: "XGRO:"), + try ledgerLookup.ledgerAccountName(of: account, symbol: "XGRO:"), throws: WealthsimpleConversionError.invalidCommoditySymbol("XGRO:") ) } + func testLedgerAccountNameFor() { + let ledger = Ledger() + var ledgerLookup = LedgerLookup(ledger) + var name = try! AccountName("Assets:Test") + var number = "abc123" + + // not found + assert( + try ledgerLookup.ledgerAccountName(for: .rounding, in: TestAccount(number: number), ofType: [.income]), + throws: WealthsimpleConversionError.missingAccount(MetaDataKeys.rounding, number, "Income") + ) + + // rounding + try! ledger.add(Account(name: name, metaData: [MetaDataKeys.rounding: number])) + ledgerLookup = LedgerLookup(ledger) + XCTAssertEqual(try! ledgerLookup.ledgerAccountName(for: .rounding, in: TestAccount(number: number), ofType: [.asset] ), name) + + // wrong type + assert( + try ledgerLookup.ledgerAccountName(for: .rounding, in: TestAccount(number: number), ofType: [.income, .expense, .equity] ), + throws: WealthsimpleConversionError.missingAccount(MetaDataKeys.rounding, number, "Income, or Expenses, or Equity") + ) + + // multiple numbers + name = try! AccountName("Assets:Test:Two") + number = "def456" + let number2 = "ghi789" + try! ledger.add(Account(name: name, metaData: [MetaDataKeys.rounding: "\(number) \(number2)"])) + ledgerLookup = LedgerLookup(ledger) + XCTAssertEqual(try! ledgerLookup.ledgerAccountName(for: .rounding, in: TestAccount(number: number), ofType: [.asset] ), name) + XCTAssertEqual(try! ledgerLookup.ledgerAccountName(for: .rounding, in: TestAccount(number: number2), ofType: [.asset] ), name) + + // contribution room + name = try! AccountName("Assets:Test:Three") + try! ledger.add(Account(name: name, metaData: [MetaDataKeys.contributionRoom: number])) + ledgerLookup = LedgerLookup(ledger) + XCTAssertEqual(try! ledgerLookup.ledgerAccountName(for: .contributionRoom, in: TestAccount(number: number), ofType: [.asset] ), name) + + // dividend + transaction type multi key + name = try! AccountName("Income:Test") + let symbol = "XGRO" + try! ledger.add(Account(name: name, metaData: ["\(MetaDataKeys.dividendPrefix)\(symbol)": number, "\(MetaDataKeys.prefix)giveaway-bonus": number])) + ledgerLookup = LedgerLookup(ledger) + XCTAssertEqual(try! ledgerLookup.ledgerAccountName(for: .dividend(symbol), in: TestAccount(number: number), ofType: [.income] ), name) + XCTAssertEqual(try! ledgerLookup.ledgerAccountName(for: .transactionType(.giveawayBonus), in: TestAccount(number: number), ofType: [.income] ), name) + } + func testDoesTransactionExistInLedger() { let ledger = Ledger() var metaData = TransactionMetaData(date: Date(), metaData: [MetaDataKeys.id: "abc"]) diff --git a/Tests/SwiftBeanCountWealthsimpleMapperTests/WealthsimpleConversionErrorTests.swift b/Tests/SwiftBeanCountWealthsimpleMapperTests/WealthsimpleConversionErrorTests.swift index 4206b6b..af2fba1 100644 --- a/Tests/SwiftBeanCountWealthsimpleMapperTests/WealthsimpleConversionErrorTests.swift +++ b/Tests/SwiftBeanCountWealthsimpleMapperTests/WealthsimpleConversionErrorTests.swift @@ -9,10 +9,10 @@ final class WealthsimpleConversionErrorTests: XCTestCase { "The Commodity CAD was not found in your ledger. Please make sure you add the metadata \"wealthsimple-symbol: \"CAD\"\" to it." ) XCTAssertEqual( - "\(WealthsimpleConversionError.missingAccount("keyName", "categoryName", "typeName").localizedDescription)", + "\(WealthsimpleConversionError.missingAccount("key-name", "number123", "categoryName").localizedDescription)", """ - The categoryName account for account type typeName and key keyName was not found in your ledger. \ - Please make sure you add the metadata \"wealthsimple-key: \"keyName" wealthsimple-account-type: \"typeName\"\" to it. + The categoryName account for account number number123 and key key-name was not found in your ledger. \ + Please make sure you add the metadata \"key-name: \"number123\"\" to it. """ ) XCTAssertEqual(