Skip to content

Commit

Permalink
Adjust account meta data to allow specifying separately per account
Browse files Browse the repository at this point in the history
Currently the keys are applied to all account types. This change
refactors the meta data handling, so that for every type the
Wealthsimple accounts can be set separately.
It also now only requires one entry if an account is only used for one
type.

To see how to apply meta data now, see the readme.

Fixes #41
  • Loading branch information
Nef10 committed Sep 19, 2021
1 parent 1ef4482 commit d663161
Show file tree
Hide file tree
Showing 9 changed files with 158 additions and 92 deletions.
71 changes: 32 additions & 39 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`

<details>
<summary>Full Example</summary>
Expand All @@ -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"
````
</details>

Expand Down
Original file line number Diff line number Diff line change
@@ -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()
}

}
53 changes: 19 additions & 34 deletions Sources/SwiftBeanCountWealthsimpleMapper/LedgerLookup.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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

Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -174,5 +159,5 @@ struct LedgerLookup {
}
}

extension Wealthsimple.Account: WealthsimpleAccountRepresentable {
extension Wealthsimple.Account: AccountNumberProvider {
}
18 changes: 15 additions & 3 deletions Sources/SwiftBeanCountWealthsimpleMapper/MetaDataKeys.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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"

}
Original file line number Diff line number Diff line change
Expand Up @@ -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 """
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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(), "")
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -36,33 +34,80 @@ 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")
)

// 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)
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"])
Expand Down
Loading

0 comments on commit d663161

Please sign in to comment.