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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +8 -0
- data/docs/guides/tools/dam/dam-usage.md +261 -471
- data/lib/appydave/tools/bank_reconciliation/_doc.md +36 -0
- data/lib/appydave/tools/bank_reconciliation/clean/clean_transactions.rb +159 -0
- data/lib/appydave/tools/bank_reconciliation/clean/mapper.rb +133 -0
- data/lib/appydave/tools/bank_reconciliation/clean/read_transactions.rb +258 -0
- data/lib/appydave/tools/bank_reconciliation/models/transaction.rb +158 -0
- data/lib/appydave/tools/configuration/models/bank_reconciliation_config.rb +152 -0
- data/lib/appydave/tools/version.rb +1 -1
- data/lib/appydave/tools.rb +7 -0
- data/package.json +1 -1
- metadata +22 -2
|
@@ -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
|