-
Notifications
You must be signed in to change notification settings - Fork 0
/
SwiftBeanCountCompassCardMapper.swift
244 lines (214 loc) · 11.3 KB
/
SwiftBeanCountCompassCardMapper.swift
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
import CSV
import Foundation
import SwiftBeanCountModel
import SwiftBeanCountParserUtils
/// Mapper to map downloaded accounts and transactions to BeanCoutModel objects
public struct SwiftBeanCountCompassCardMapper {
private struct TransactionRow: Decodable {
let date: Date
let transaction: String
let amount: String
let journeyId: String?
private enum CodingKeys: String, CodingKey { // swiftlint:disable:this nesting
case date = "DateTime"
case transaction = "Transaction"
case amount = "Amount"
case journeyId = "JourneyId"
}
}
private enum MetaDataKey {
static let importerType = "importer-type"
static let importerTypeValue = "compass-card"
static let cardNumber = "card-number"
static let journeyId = "journey-id"
static let expense = "compass-card-expense"
static let autoLoad = "compass-card-load"
}
private static var dateFormatter: DateFormatter = {
var dateFormatter = DateFormatter()
dateFormatter.dateFormat = "MMM-dd-yyyy hh:mm a"
dateFormatter.timeZone = TimeZone(abbreviation: "PST")
return dateFormatter
}()
private static var dateFormatterLoadId: DateFormatter = {
var dateFormatter = DateFormatter()
dateFormatter.dateFormat = "yyyy-MM-dd-HH-mm"
dateFormatter.timeZone = TimeZone(abbreviation: "PST")
return dateFormatter
}()
/// AccountName for the other leg of a transaction
public let defaultExpenseAccountName = try! AccountName("Expenses:TODO") // swiftlint:disable:this force_try
/// AccountName for the other leg of a load transaction
public let defaultAssetAccountName = try! AccountName("Assets:TODO") // swiftlint:disable:this force_try
/// String in CSV
private let autoLoadTransaction = "AutoLoaded"
/// Strings in CSV
private let removeTransactionDescriptions = ["Tap in at", "Tap out at", "Transfer at", "Stn"]
private let payee = "TransLink"
private let commodity = "CAD"
private let ledger: Ledger
/// Creates a mapper
/// - Parameter ledger: Ledger which will be used to look up things like account names
public init(ledger: Ledger) {
self.ledger = ledger
}
/// Creates a balance assertions from the downloaded string
/// - Parameters:
/// - cardNumber: String with the compass card number
/// - balance: String with the balance
/// - date: Date to balance assertion should use, if nil defaults to tomorrow
/// - Returns: Array of Balances
public func createBalance(cardNumber: String, balance: String, date inputDate: Date? = nil) throws -> Balance {
let date = inputDate ?? Calendar.current.date(byAdding: .day, value: 1, to: Date())!
let number = cardNumber.components(separatedBy: .whitespacesAndNewlines).joined()
let balanceString = balance.replacingOccurrences(of: "$", with: "").components(separatedBy: .whitespacesAndNewlines).joined()
let (decimal, _) = balanceString.amountDecimal()
let amount = Amount(number: decimal, commoditySymbol: commodity, decimalDigits: 2)
return try Balance(date: date, accountName: ledgerCardAccountName(cardNumber: number), amount: amount)
}
/// Creates Transactions from the downloaded CSV String
///
/// Note: This method filters out transactions already existing in the ledger
///
/// - Parameters:
/// - cardNumber: String with the compass card number
/// - transactions: String of the transaction CSV
/// - Returns: Array of transactions
public func createTransactions(cardNumber: String, transactions: String) throws -> [Transaction] {
let account = try ledgerCardAccountName(cardNumber: cardNumber)
let reader = try CSVReader(string: transactions, hasHeaderRow: true)
return try createTransactions(getRows(reader), cardNumber: cardNumber, account: account)
}
/// Creates Transactions from a provided CSVReader
///
/// Note: This method filters out transactions already existing in the ledger
///
/// - Parameters:
/// - account: AccountName of asset account in the ledger
/// - reader: CSVReader with the transaction CSV
/// - Returns: Array of transactions
public func createTransactions(account: AccountName, reader: CSVReader) throws -> [Transaction] {
try createTransactions(getRows(reader), cardNumber: nil, account: account)
}
/// Gets the correct account for the Compass Card from the ledger based on the card number
/// - Parameter cardNumber: Compass Card Number
/// - Returns: AccountName from the ledger
public func ledgerCardAccountName(cardNumber: String) throws -> AccountName {
guard let accountName = ledger.accounts.first(where: {
$0.metaData[MetaDataKey.importerType] == MetaDataKey.importerTypeValue && $0.metaData[MetaDataKey.cardNumber] == cardNumber
})?.name else {
throw SwiftBeanCountCompassCardMapperError.missingAccount(cardNumber: cardNumber)
}
return accountName
}
private func getRows(_ reader: CSVReader) throws -> [TransactionRow] {
var rows = [TransactionRow]()
let decoder = CSVRowDecoder()
decoder.dateDecodingStrategy = .formatted(Self.dateFormatter)
while reader.next() != nil {
let row = try decoder.decode(TransactionRow.self, from: reader)
rows.append(row)
}
return rows
}
private func createTransactions(_ transactions: [TransactionRow], cardNumber: String?, account: AccountName) throws -> [Transaction] {
var result = [Transaction]()
var currentJourney = ""
var currentTransactions = [TransactionRow]()
for transaction in transactions {
if transaction.transaction == autoLoadTransaction {
result.append(createAutoLoadTransaction(transaction, cardNumber: cardNumber, account: account))
} else {
if currentJourney == transaction.journeyId {
currentTransactions.append(transaction)
} else {
if !currentTransactions.isEmpty {
if let transaction = createTransaction(currentTransactions, cardNumber: cardNumber, account: account) {
result.append(transaction)
}
}
currentJourney = transaction.journeyId ?? ""
currentTransactions = [transaction]
}
}
}
if !currentTransactions.isEmpty {
if let transaction = createTransaction(currentTransactions, cardNumber: cardNumber, account: account) {
result.append(transaction)
}
}
return result.filter { !doesTransactionExistInLedger($0.metaData.metaData["journey-id"] ?? "") }
}
private func createTransaction(_ transactions: [TransactionRow], cardNumber: String?, account: AccountName) -> Transaction? {
var transactionRows = transactions
transactionRows.sort { $0.date < $1.date }
var amount: MultiCurrencyAmount = Amount(number: Decimal(), commoditySymbol: commodity, decimalDigits: 2).multiCurrencyAmount
var narration = ""
for transaction in transactionRows {
let balanceString = transaction.amount.replacingOccurrences(of: "$", with: "").components(separatedBy: .whitespacesAndNewlines).joined()
let (decimal, _) = balanceString.amountDecimal()
amount += Amount(number: decimal, commoditySymbol: commodity, decimalDigits: 2)
let stop = removeTransactionDescriptions.reduce(transaction.transaction) {
$0.replacingOccurrences(of: $1, with: "").trimmingCharacters(in: .whitespacesAndNewlines)
}
if narration.isEmpty {
narration = stop
} else if !narration.hasSuffix(" -> \(stop)") {
narration.append(contentsOf: " -> \(stop)")
}
}
if amount.amountFor(symbol: commodity).number == Decimal() {
return nil
}
let expenseAccount = ledgerExpenseAccountName(cardNumber: cardNumber)
let posting = Posting(accountName: account, amount: amount.amountFor(symbol: commodity))
let posting2 = Posting(accountName: expenseAccount, amount: Amount(number: -amount.amountFor(symbol: commodity).number, commoditySymbol: commodity, decimalDigits: 2))
let id = transactions.first!.journeyId!
let metaData = TransactionMetaData(date: transactions.first!.date, payee: payee, narration: narration, metaData: [MetaDataKey.journeyId: id])
return Transaction(metaData: metaData, postings: [posting, posting2])
}
private func createAutoLoadTransaction(_ transaction: TransactionRow, cardNumber: String?, account: AccountName) -> Transaction {
let expenseAccount = ledgerLoadAccountName(cardNumber: cardNumber)
let balanceString = transaction.amount.replacingOccurrences(of: "$", with: "").components(separatedBy: .whitespacesAndNewlines).joined()
let (decimal, _) = balanceString.amountDecimal()
let posting = Posting(accountName: account, amount: Amount(number: decimal, commoditySymbol: commodity, decimalDigits: 2))
let posting2 = Posting(accountName: expenseAccount, amount: Amount(number: -decimal, commoditySymbol: commodity, decimalDigits: 2))
let id = "\(MetaDataKey.autoLoad)-\(Self.dateFormatterLoadId.string(from: transaction.date))"
let metaData = TransactionMetaData(date: transaction.date, narration: "", metaData: [MetaDataKey.journeyId: id])
return Transaction(metaData: metaData, postings: [posting, posting2])
}
/// Gets the correct account from the ledger for an expense based on the card number
/// - Parameter cardNumber: Compass Card Number
/// - Returns: AccountName from the ledger, or fallback if not found
private func ledgerExpenseAccountName(cardNumber: String?) -> AccountName {
guard let cardNumber else {
return defaultExpenseAccountName
}
guard let accountName = ledger.accounts.first(where: {
$0.metaData[MetaDataKey.expense]?.contains(cardNumber) ?? false
})?.name else {
return defaultExpenseAccountName
}
return accountName
}
/// Gets the correct account from the ledger for a load of the card, based on the card number
/// - Parameter cardNumber: Compass Card Number
/// - Returns: AccountName from the ledger, or fallback if not found
private func ledgerLoadAccountName(cardNumber: String?) -> AccountName {
guard let cardNumber else {
return defaultAssetAccountName
}
guard let accountName = ledger.accounts.first(where: {
$0.metaData[MetaDataKey.autoLoad]?.contains(cardNumber) ?? false
})?.name else {
return defaultAssetAccountName
}
return accountName
}
/// Checks if a transaction is already in the ledger
/// - Parameter journeyId: journeyId of the transaction to check
/// - Returns: if the transaction with this id is already in the ledger
private func doesTransactionExistInLedger(_ journeyId: String) -> Bool {
ledger.transactions.contains { $0.metaData.metaData[MetaDataKey.journeyId]?.contains(journeyId) ?? false }
}
}