appydave-tools 0.85.0 → 0.86.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,36 @@
1
+ # Bank reconciliation
2
+
3
+ [ChatGPT conversation](https://chatgpt.com/c/5d382562-95e5-4243-9b74-c3807d363486)
4
+
5
+
6
+ ## Code structure
7
+
8
+ ```bash
9
+ ├─ lib
10
+ │ ├─ appydave
11
+ │ │ └─ tools
12
+ │ │ ├─ bank_reconciliation
13
+ │ │ │ ├─ clean
14
+ │ │ │ │ ├─ read_transactions.rb
15
+ │ │ │ │ ├─ transaction_cleaner.rb
16
+ │ │ │ ├─ models
17
+ │ │ │ │ ├─ raw_transaction.rb
18
+ │ │ │ │ └─ reconciled_transaction.rb
19
+ │ │ └─ configuration
20
+ │ │ └─ models
21
+ │ │ └─ bank_reconciliation_config.rb
22
+ └─ spec
23
+ ├─ appydave
24
+ │ ├─ tools
25
+ │ │ ├─ bank_reconciliation
26
+ │ │ │ ├─ clean
27
+ │ │ │ │ ├─ read_transactions_spec.rb
28
+ │ │ │ ├─ models
29
+ │ │ │ │ └─ raw_transaction_spec.rb
30
+ │ │ └─ configuration
31
+ │ │ └─ models
32
+ │ │ └─ bank_reconciliation_config_spec.rb
33
+ └─ fixtures
34
+ └─ bank-reconciliation
35
+ └─ bank-west.csv
36
+ ```
@@ -0,0 +1,159 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'clipboard'
4
+
5
+ module Appydave
6
+ module Tools
7
+ module BankReconciliation
8
+ module Clean
9
+ # Clean transactions
10
+ class CleanTransactions
11
+ include KLog::Logging
12
+ include Appydave::Tools::Configuration::Configurable
13
+ include Appydave::Tools::Debuggable
14
+
15
+ attr_reader :transaction_folder
16
+ attr_reader :output_folder
17
+ attr_reader :transactions
18
+
19
+ # (config_file)
20
+ def initialize(transaction_folder: nil, output_folder: nil, debug: false)
21
+ @debug = debug
22
+ # needs to use config.bank_reconciliation.transaction_folder
23
+ transaction_folder ||= '/Volumes/Expansion/Sync/bank-reconciliation/original-transactions'
24
+ output_folder ||= File.join(transaction_folder, 'clean')
25
+
26
+ @transaction_folder = transaction_folder
27
+ @output_folder = output_folder
28
+ end
29
+
30
+ def clean_transactions(input_globs, output_file)
31
+ log_info("Starting transaction cleaning with input patterns: #{input_globs}")
32
+
33
+ raw_transactions = grab_raw_transactions(input_globs)
34
+ log_info("Total raw transactions collected: #{raw_transactions.size}")
35
+
36
+ transactions, duplicates_count = deduplicate_across_files(raw_transactions)
37
+ log_info("Duplicates found and removed: #{duplicates_count}")
38
+
39
+ transactions = Mapper.new.map(transactions)
40
+ log_info('Transactions mapped to chart of accounts and bank accounts')
41
+
42
+ log_kv 'Deduped consolidated transactions', duplicates_count if duplicates_count.positive?
43
+
44
+ save_to_csv(transactions, output_file)
45
+
46
+ csv_to_clipboard(output_file)
47
+
48
+ @transactions = transactions
49
+ end
50
+
51
+ private
52
+
53
+ def grab_raw_transactions(input_globs)
54
+ original_dir = Dir.pwd
55
+ transactions = []
56
+
57
+ begin
58
+ Dir.chdir(transaction_folder)
59
+
60
+ input_globs.each do |glob|
61
+ Dir.glob(glob).each do |file|
62
+ log_kv 'Reading transactions from', file
63
+ raw_transactions = ReadTransactions.new(file).read
64
+ deduped_transactions, duplicates_count = deduplicate_within_file(raw_transactions)
65
+
66
+ if duplicates_count.positive?
67
+ log_kv 'Duplicates count within file', duplicates_count
68
+ log_kv 'File', file
69
+ end
70
+
71
+ transactions += deduped_transactions
72
+ end
73
+ end
74
+ ensure
75
+ Dir.chdir(original_dir)
76
+ end
77
+
78
+ transactions
79
+ end
80
+
81
+ def deduplicate_within_file(transactions)
82
+ unique_transactions = transactions.uniq do |transaction|
83
+ [
84
+ transaction.bsb_number,
85
+ transaction.account_number,
86
+ transaction.transaction_date,
87
+ transaction.narration,
88
+ transaction.cheque_number,
89
+ transaction.debit,
90
+ transaction.credit,
91
+ transaction.balance,
92
+ transaction.transaction_type
93
+ ]
94
+ end
95
+
96
+ duplicates = transactions.size - unique_transactions.size
97
+
98
+ [unique_transactions, duplicates]
99
+ end
100
+
101
+ def deduplicate_across_files(transactions)
102
+ grouped_transactions = transactions.group_by do |transaction|
103
+ [
104
+ transaction.bsb_number,
105
+ transaction.account_number,
106
+ transaction.transaction_date,
107
+ transaction.narration,
108
+ transaction.cheque_number,
109
+ transaction.debit,
110
+ transaction.credit,
111
+ transaction.balance,
112
+ transaction.transaction_type
113
+ ]
114
+ end
115
+
116
+ unique_transactions = []
117
+ duplicates_count = 0
118
+
119
+ grouped_transactions.each_value do |dupes|
120
+ unique_transaction = dupes.first
121
+ unique_transaction.source_files = dupes.flat_map(&:source_files).uniq
122
+ duplicates_count += dupes.size - 1
123
+ unique_transactions << unique_transaction
124
+ end
125
+
126
+ [unique_transactions, duplicates_count]
127
+ end
128
+
129
+ def full_output_file(output_file)
130
+ File.join(output_folder, output_file)
131
+ end
132
+
133
+ def save_to_csv(transactions, output_file)
134
+ FileUtils.mkdir_p(output_folder)
135
+ output_file = full_output_file(output_file)
136
+ # puts "Output file: #{output_file}"
137
+
138
+ # lets sort the transactions by the by the transaction date which is formated as 2023-07-10
139
+ transactions.sort_by!(&:transaction_date)
140
+
141
+ CSV.open(output_file, 'w') do |csv|
142
+ csv << Appydave::Tools::BankReconciliation::Models::Transaction.csv_headers
143
+ transactions.each do |transaction|
144
+ csv << transaction.to_csv_row
145
+ end
146
+ end
147
+
148
+ log_kv('Transaction Output File', full_output_file(output_file))
149
+ end
150
+
151
+ def csv_to_clipboard(output_file)
152
+ Clipboard.copy(File.read(full_output_file(output_file)))
153
+ log_info('Transactions copied to clipboard')
154
+ end
155
+ end
156
+ end
157
+ end
158
+ end
159
+ end
@@ -0,0 +1,133 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Appydave
4
+ module Tools
5
+ module BankReconciliation
6
+ module Clean
7
+ # Map transactions to chart of accounts and bank accounts
8
+ class Mapper
9
+ include Appydave::Tools::Configuration::Configurable
10
+
11
+ def map(transactions)
12
+ # tp transactions, :coa_match_type, :coa_code, :narration, :debit, :credit
13
+ transactions.map do |original_transaction|
14
+ transaction = original_transaction.dup
15
+
16
+ transaction = map_chart_of_account(transaction)
17
+ clean_amount(transaction)
18
+ map_bank_account(transaction)
19
+ end
20
+ end
21
+
22
+ private
23
+
24
+ def map_chart_of_account(transaction)
25
+ equality_match(transaction) ||
26
+ trigram_match(transaction, 0.9, '90%') ||
27
+ trigram_match(transaction, 0.8, '80%') ||
28
+ trigram_match(transaction, 0.7, '70%') ||
29
+ trigram_match(transaction, 0.6, '60%') ||
30
+ trigram_match(transaction, 0.5, '50%') ||
31
+ start_with_match(transaction) ||
32
+ includes(transaction)
33
+ transaction
34
+ end
35
+
36
+ def map_bank_account(transaction)
37
+ bank_account = config.bank_reconciliation.get_bank_account(transaction.account_number, transaction.bsb_number)
38
+
39
+ if bank_account
40
+ transaction.account_name = bank_account.name
41
+ transaction.platform = bank_account.platform
42
+ else
43
+ puts "Bank account not found for BSB#: #{transaction.bsb_number} | Account#: #{transaction.account_number}"
44
+ end
45
+
46
+ transaction
47
+ end
48
+
49
+ def equality_match(transaction)
50
+ coa = config.bank_reconciliation.chart_of_accounts.find do |chart_of_account|
51
+ chart_of_account.narration.to_s.delete(' ').downcase == transaction.narration.delete(' ').downcase
52
+ end
53
+
54
+ return nil unless coa
55
+
56
+ transaction.coa_match_type = 'equality'
57
+ transaction.coa_code = coa.code
58
+ transaction
59
+ end
60
+
61
+ def start_with_match(transaction)
62
+ coa = config.bank_reconciliation.chart_of_accounts.find do |chart_of_account|
63
+ transaction.narration.to_s.delete(' ').downcase.start_with?(chart_of_account.narration.to_s.downcase)
64
+ end
65
+
66
+ return nil unless coa
67
+
68
+ transaction.coa_match_type = 'starts_with'
69
+ transaction.coa_code = coa.code
70
+ transaction
71
+ end
72
+
73
+ def includes(transaction)
74
+ coa = config.bank_reconciliation.chart_of_accounts.find do |chart_of_account|
75
+ transaction.narration.to_s.delete(' ').downcase.include?(chart_of_account.narration.delete(' ').to_s.downcase)
76
+ end
77
+
78
+ return nil unless coa
79
+
80
+ transaction.coa_match_type = 'includes'
81
+ transaction.coa_code = coa.code
82
+ transaction
83
+ end
84
+
85
+ def trigram_match(transaction, score_threshold, match_type)
86
+ scored_transactions = config.bank_reconciliation.chart_of_accounts.map do |coa|
87
+ {
88
+ coa: coa,
89
+ score: compare(coa.narration, transaction.narration)
90
+ }
91
+ end
92
+
93
+ scored_transactions.sort_by! { |t| t[:score] }.reverse!
94
+
95
+ best = scored_transactions.first
96
+
97
+ return nil unless best
98
+ return nil if best[:score] < score_threshold
99
+
100
+ coa = best[:coa]
101
+
102
+ transaction.coa_match_type = match_type
103
+ transaction.coa_code = coa.code
104
+ transaction
105
+ end
106
+
107
+ def compare(text1, text2)
108
+ text1_trigs = trigramify(text1)
109
+ text2_trigs = trigramify(text2)
110
+
111
+ all_cnt = (text1_trigs | text2_trigs).size
112
+ same_cnt = (text1_trigs & text2_trigs).size
113
+
114
+ same_cnt.to_f / all_cnt
115
+ # rescue StandardError => e
116
+ # puts "Error comparing text: #{e.message}"
117
+ end
118
+
119
+ def trigramify(text)
120
+ trigs = []
121
+ text.chars.each_cons(3) { |v| trigs << v.join }
122
+ trigs
123
+ end
124
+
125
+ def clean_amount(transaction)
126
+ transaction.debit = transaction.clean_amount(transaction.debit)
127
+ transaction.credit = transaction.clean_amount(transaction.credit)
128
+ end
129
+ end
130
+ end
131
+ end
132
+ end
133
+ end
@@ -0,0 +1,258 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'bigdecimal'
4
+
5
+ module Appydave
6
+ module Tools
7
+ module BankReconciliation
8
+ module Clean
9
+ # Read transactions from a CSV file
10
+ class ReadTransactions
11
+ WISE_HEADER_PREFIX = 'ID,Status,Direction,"Created on","Finished on","Source fee amount",' \
12
+ '"Source fee currency","Target fee amount","Target fee currency","Source name",' \
13
+ '"Source amount (after fees)","Source currency","Target name",' \
14
+ '"Target amount (after fees)","Target currency","Exchange rate",Reference,Batch'
15
+ PAYPAL_HEADER_PREFIX = '"Date","Time","Time zone","Name","Type","Status","Currency",' \
16
+ '"Amount","Fees","Total","Exchange Rate","Receipt ID","Balance",' \
17
+ '"Transaction ID","Item Title"'
18
+ # Exact match — short header risks start_with? collision with future formats
19
+ COMMONWEALTH_SIMPLE_HEADER = 'Date,Amount,Description,Balance'
20
+ attr_reader :platform
21
+ attr_reader :transactions
22
+
23
+ def initialize(file)
24
+ @file = file
25
+ end
26
+
27
+ def read
28
+ csv_lines = File.read(@file).lines
29
+ csv_lines[0] = csv_lines[0].sub("\xEF\xBB\xBF", '') if csv_lines[0]&.start_with?("\xEF\xBB\xBF")
30
+
31
+ @platform = detect_platform(csv_lines)
32
+
33
+ case platform
34
+ when :bankwest
35
+ read_bankwest(csv_lines)
36
+ when :bankwest2
37
+ read_bankwest2(csv_lines)
38
+ when :commonwealth1
39
+ read_commonwealth1(csv_lines)
40
+ when :wise
41
+ read_wise(csv_lines)
42
+ when :paypal
43
+ read_paypal(csv_lines)
44
+ when :commonwealth_simple
45
+ read_commonwealth_simple(csv_lines)
46
+ else
47
+ raise Appydave::Tools::Error, 'Unknown platform X'
48
+ end
49
+ end
50
+
51
+ private
52
+
53
+ def read_bankwest(csv_lines)
54
+ @transactions = []
55
+
56
+ # Skip the header line and parse each subsequent line
57
+ CSV.parse(csv_lines.join, headers: true).each do |row|
58
+ transaction = Models::Transaction.new(
59
+ bsb_number: row['BSB Number'] || '',
60
+ account_number: row['Account Number'] || '',
61
+ transaction_date: row['Transaction Date'],
62
+ narration: row['Narration'],
63
+ cheque_number: row['Cheque Number'],
64
+ debit: row['Debit'],
65
+ credit: row['Credit'],
66
+ balance: row['Balance'],
67
+ transaction_type: row['Transaction Type']
68
+ )
69
+ transaction.add_source_file(@file)
70
+ @transactions << transaction
71
+ end
72
+
73
+ @transactions
74
+ end
75
+
76
+ def read_bankwest2(csv_lines)
77
+ @transactions = []
78
+
79
+ # Skip the header line and parse each subsequent line
80
+ CSV.parse(csv_lines.join, headers: true).each do |row|
81
+ values = row['BSB / Account Number'].split(' - ')
82
+
83
+ transaction = Models::Transaction.new(
84
+ bsb_number: values.length > 1 ? values.first : '',
85
+ account_number: values.length > 1 ? values.last : row['BSB / Account Number'],
86
+ transaction_date: row['Transaction Date'],
87
+ narration: row['Narration'],
88
+ cheque_number: row['Cheque Number'],
89
+ debit: row['Debit'],
90
+ credit: row['Credit'],
91
+ balance: row['Balance'],
92
+ transaction_type: row['Transaction Type']
93
+ )
94
+ transaction.add_source_file(@file)
95
+ @transactions << transaction
96
+ end
97
+
98
+ @transactions
99
+ end
100
+
101
+ def read_commonwealth1(csv_lines)
102
+ @transactions = []
103
+
104
+ # Skip the header line and parse each subsequent line
105
+ CSV.parse(csv_lines.join, headers: true).each do |row|
106
+ transaction = Models::Transaction.new(
107
+ bsb_number: row['bsb_number'],
108
+ account_number: row['account_number'],
109
+ transaction_date: row['transaction_date'],
110
+ narration: row['description'],
111
+ cheque_number: '',
112
+ debit: row['amount'].to_f.negative? ? row['amount'] : '',
113
+ credit: row['amount'].to_f.positive? ? row['amount'] : '',
114
+ balance: row['balance'],
115
+ transaction_type: ''
116
+ )
117
+ transaction.add_source_file(@file)
118
+ @transactions << transaction
119
+ end
120
+
121
+ @transactions
122
+ end
123
+
124
+ # Wise CSV header:
125
+ # ID,Status,Direction,"Created on","Finished on","Source fee amount","Source fee currency",
126
+ # "Target fee amount","Target fee currency","Source name","Source amount (after fees)",
127
+ # "Source currency","Target name","Target amount (after fees)","Target currency",
128
+ # "Exchange rate",Reference,Batch
129
+ def read_wise(csv_lines)
130
+ @transactions = []
131
+
132
+ CSV.parse(csv_lines.join, headers: true).each do |row|
133
+ amount = row['Source amount (after fees)']
134
+ incoming = row['Direction'].to_s.strip.upcase == 'IN'
135
+
136
+ puts "Wise: unexpected Direction '#{row['Direction']}' on row #{row['ID']} — defaulting to debit" \
137
+ unless %w[IN OUT].include?(row['Direction'].to_s.strip.upcase)
138
+
139
+ transaction = Models::Transaction.new(
140
+ bsb_number: '',
141
+ account_number: 'WISE',
142
+ transaction_date: row['Created on'],
143
+ narration: row['Reference'] || '',
144
+ cheque_number: "#{row['Source currency']}|#{row['Target currency']}|#{row['Exchange rate']}|#{row['Target name']}",
145
+ debit: incoming ? '' : amount,
146
+ credit: incoming ? amount : '',
147
+ balance: '',
148
+ transaction_type: ''
149
+ )
150
+ transaction.add_source_file(@file)
151
+ @transactions << transaction
152
+ end
153
+
154
+ @transactions
155
+ end
156
+
157
+ # PayPal CSV header:
158
+ # "Date","Time","Time zone","Name","Type","Status","Currency","Amount","Fees","Total",
159
+ # "Exchange Rate","Receipt ID","Balance","Transaction ID","Item Title"
160
+ def read_paypal(csv_lines)
161
+ @transactions = []
162
+
163
+ CSV.parse(csv_lines.join, headers: true).each do |row|
164
+ next if row['Date'].to_s.strip.empty?
165
+
166
+ name = row['Name'].to_s.strip
167
+ item = row['Item Title'].to_s.strip
168
+ narration = name.empty? ? item : name
169
+ currency = row['Currency'].to_s.strip
170
+ narration = "#{narration} (#{currency})" if !currency.empty? && currency != 'AUD'
171
+
172
+ amount = row['Amount'].to_s.strip
173
+ debit = amount.to_f.negative? ? amount : ''
174
+ credit = amount.to_f.positive? ? amount : ''
175
+
176
+ transaction = Models::Transaction.new(
177
+ bsb_number: '',
178
+ account_number: 'PAYPAL',
179
+ transaction_date: row['Date'],
180
+ narration: narration,
181
+ cheque_number: '',
182
+ debit: debit,
183
+ credit: credit,
184
+ balance: row['Balance'],
185
+ transaction_type: row['Type'].to_s
186
+ )
187
+ transaction.add_source_file(@file)
188
+ @transactions << transaction
189
+ end
190
+
191
+ @transactions
192
+ end
193
+
194
+ # CBA 4-column export (Date,Amount,Description,Balance). The CSV carries
195
+ # no account identity, so we tag rows with the generic identifier 'CBA-SIMPLE'
196
+ # and let the downstream mapper resolve it via the local
197
+ # ~/.config/appydave/bank-reconciliation.json config (same pattern as WISE
198
+ # and PAYPAL). Real BSB / account number stay out of source.
199
+ def read_commonwealth_simple(csv_lines)
200
+ @transactions = []
201
+
202
+ CSV.parse(csv_lines.join, headers: true).each do |row|
203
+ next if row['Date'].to_s.strip.empty?
204
+
205
+ amount = parse_signed_amount(row['Amount'])
206
+ debit = amount.negative? ? amount.to_s('F') : ''
207
+ credit = amount.positive? ? amount.to_s('F') : ''
208
+
209
+ transaction = Models::Transaction.new(
210
+ bsb_number: '',
211
+ account_number: 'CBA-SIMPLE',
212
+ transaction_date: row['Date'],
213
+ narration: row['Description'].to_s,
214
+ cheque_number: nil,
215
+ debit: debit,
216
+ credit: credit,
217
+ balance: parse_signed_amount(row['Balance']).to_s('F'),
218
+ transaction_type: nil
219
+ )
220
+ transaction.add_source_file(@file)
221
+ @transactions << transaction
222
+ end
223
+
224
+ @transactions
225
+ end
226
+
227
+ # For bankwest the first row is the CSV will look like:
228
+ # BSB Number,Account Number,Transaction Date,Narration,Cheque Number,Debit,Credit,Balance,Transaction Type
229
+ def detect_platform(csv_lines)
230
+ return :bankwest if csv_lines.first.start_with?('BSB Number,Account Number,Transaction Date,Narration,Cheque Number,Debit,Credit,Balance,Transaction Type')
231
+ return :bankwest2 if csv_lines.first.start_with?('Account Name,BSB / Account Number,Transaction Date,Narration,Cheque Number,Debit,Credit,Balance,Transaction Type')
232
+ return :commonwealth1 if csv_lines.first.start_with?('bsb_number,account_number,transaction_date,amount,description,balance') # Standard Account
233
+ return :commonwealth2 if csv_lines.first.start_with?('transaction_date,narration,debit_credit_amount,debit_credit_currency,balance_amount,balance_currency') # Travel Money
234
+ return :wise if csv_lines.first.start_with?(WISE_HEADER_PREFIX)
235
+ return :paypal if csv_lines.first.start_with?(PAYPAL_HEADER_PREFIX)
236
+ return :commonwealth_simple if csv_lines.first.strip == COMMONWEALTH_SIMPLE_HEADER
237
+
238
+ puts "Unknown platform detected. CSV columns are: #{csv_lines.first.strip}"
239
+ raise Appydave::Tools::Error, 'Unknown platform'
240
+ end
241
+
242
+ def parse_signed_amount(str)
243
+ return BigDecimal('0') if str.nil? || str.to_s.strip.empty?
244
+
245
+ clean = str.to_s.gsub(/[$,\s"]/, '')
246
+ return BigDecimal('0') if clean.empty?
247
+
248
+ case clean[0]
249
+ when '+' then BigDecimal(clean[1..])
250
+ when '-' then -BigDecimal(clean[1..])
251
+ else BigDecimal(clean)
252
+ end
253
+ end
254
+ end
255
+ end
256
+ end
257
+ end
258
+ end