appydave-tools 0.84.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 +15 -0
- data/CLAUDE.md +1 -1
- data/CONTEXT.md +133 -23
- data/bin/configuration.rb +20 -0
- data/bin/query_apps.rb +74 -0
- data/config/examples/locations.example.json +48 -0
- data/config/examples/settings.example.json +9 -0
- data/config/random-queries.yml +24 -45
- data/context.globs.json +24 -0
- data/docs/guides/tools/dam/dam-usage.md +261 -471
- data/docs/planning/multi-user-support.md +108 -0
- data/docs/planning/query-apps-design.md +344 -0
- data/docs/planning/query-skills-plan.md +354 -0
- data/docs/specs/jump-add-display-fix.md +249 -0
- data/exe/query_apps +7 -0
- data/lib/appydave/tools/app_context/app_finder.rb +176 -0
- data/lib/appydave/tools/app_context/globs_loader.rb +116 -0
- data/lib/appydave/tools/app_context/options.rb +28 -0
- 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/brain_context/options.rb +19 -2
- data/lib/appydave/tools/configuration/example_installer.rb +72 -0
- data/lib/appydave/tools/configuration/models/bank_reconciliation_config.rb +152 -0
- data/lib/appydave/tools/configuration/models/settings_config.rb +12 -0
- data/lib/appydave/tools/jump/formatters/table_formatter.rb +16 -5
- data/lib/appydave/tools/version.rb +1 -1
- data/lib/appydave/tools/zsh_history/config.rb +2 -2
- data/lib/appydave/tools/zsh_history/filter.rb +10 -4
- data/lib/appydave/tools.rb +12 -0
- data/package.json +1 -1
- metadata +36 -2
|
@@ -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
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Appydave
|
|
4
|
+
module Tools
|
|
5
|
+
module BankReconciliation
|
|
6
|
+
module Models
|
|
7
|
+
# Unified transaction model for raw and reconciled data
|
|
8
|
+
class Transaction
|
|
9
|
+
attr_accessor :bsb_number,
|
|
10
|
+
:account_number,
|
|
11
|
+
:transaction_date,
|
|
12
|
+
:narration,
|
|
13
|
+
:cheque_number,
|
|
14
|
+
:debit,
|
|
15
|
+
:credit,
|
|
16
|
+
:balance,
|
|
17
|
+
:transaction_type,
|
|
18
|
+
:platform,
|
|
19
|
+
:coa_code,
|
|
20
|
+
:coa_match_type,
|
|
21
|
+
:account_name,
|
|
22
|
+
:source_files,
|
|
23
|
+
:fin_year
|
|
24
|
+
|
|
25
|
+
def initialize(bsb_number: nil,
|
|
26
|
+
account_number: nil,
|
|
27
|
+
transaction_date: nil,
|
|
28
|
+
narration: nil,
|
|
29
|
+
cheque_number: nil,
|
|
30
|
+
debit: nil,
|
|
31
|
+
credit: nil,
|
|
32
|
+
balance: nil,
|
|
33
|
+
transaction_type: nil,
|
|
34
|
+
platform: nil,
|
|
35
|
+
coa_code: nil,
|
|
36
|
+
coa_match_type: nil,
|
|
37
|
+
account_name: nil)
|
|
38
|
+
account_number = account_number&.strip
|
|
39
|
+
@bsb_number = bsb_number&.strip
|
|
40
|
+
@account_number = account_number
|
|
41
|
+
@transaction_date = parse_date(transaction_date)
|
|
42
|
+
@narration = narration&.gsub(/\s{2,}/, ' ')&.strip
|
|
43
|
+
@cheque_number = cheque_number&.strip
|
|
44
|
+
@debit = debit # clean_amount(debit, account_number, coa_code)
|
|
45
|
+
@credit = credit # clean_amount(credit, account_number, coa_code)
|
|
46
|
+
@balance = balance&.strip
|
|
47
|
+
@transaction_type = transaction_type&.strip
|
|
48
|
+
@platform = platform
|
|
49
|
+
@coa_code = coa_code
|
|
50
|
+
@coa_match_type = coa_match_type
|
|
51
|
+
@account_name = account_name
|
|
52
|
+
@source_files = []
|
|
53
|
+
@fin_year = determine_fin_year(@transaction_date)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def add_source_file(source_file)
|
|
57
|
+
@source_files << source_file.strip unless @source_files.include?(source_file.strip)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def self.csv_headers
|
|
61
|
+
%i[
|
|
62
|
+
platform
|
|
63
|
+
account_name
|
|
64
|
+
bsb_number
|
|
65
|
+
account_number
|
|
66
|
+
transaction_date
|
|
67
|
+
narration
|
|
68
|
+
debit
|
|
69
|
+
credit
|
|
70
|
+
balance
|
|
71
|
+
transaction_type
|
|
72
|
+
coa_code
|
|
73
|
+
coa_match_type
|
|
74
|
+
source_files
|
|
75
|
+
fin_year
|
|
76
|
+
]
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def to_csv_row
|
|
80
|
+
[
|
|
81
|
+
@platform,
|
|
82
|
+
@account_name,
|
|
83
|
+
@bsb_number,
|
|
84
|
+
@account_number,
|
|
85
|
+
@transaction_date,
|
|
86
|
+
@narration,
|
|
87
|
+
@debit ? format('%.2f', @debit) : '',
|
|
88
|
+
@credit ? format('%.2f', @credit) : '',
|
|
89
|
+
@balance,
|
|
90
|
+
@transaction_type,
|
|
91
|
+
@coa_code,
|
|
92
|
+
@coa_match_type,
|
|
93
|
+
@source_files.join('; '),
|
|
94
|
+
@fin_year
|
|
95
|
+
]
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def clean_amount(amount)
|
|
99
|
+
return nil if amount.nil? || amount.strip.empty?
|
|
100
|
+
|
|
101
|
+
cleaned_amount = amount.to_f.round(2)
|
|
102
|
+
|
|
103
|
+
if swap_plus_minus_for_transactions
|
|
104
|
+
if cleaned_amount.negative?
|
|
105
|
+
cleaned_amount = cleaned_amount.abs
|
|
106
|
+
# else
|
|
107
|
+
# cleaned_amount *= -1
|
|
108
|
+
end
|
|
109
|
+
puts "Fixed negative amount for account: #{account_number} and COA: #{coa_code}, original amount: #{amount}, fixed amount: #{cleaned_amount}" if coa_code == 'DANCE'
|
|
110
|
+
end
|
|
111
|
+
cleaned_amount
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# Returns true if this transaction's amount should have its sign flipped
|
|
115
|
+
# based on the per-account, per-FY, per-COA rules stored in config.
|
|
116
|
+
#
|
|
117
|
+
# Rules live in ~/.config/appydave/bank-reconciliation.json under
|
|
118
|
+
# `sign_flip_rules` — see BankReconciliationConfig::SignFlipRule.
|
|
119
|
+
# Personal account numbers MUST NOT be hardcoded here; the config file
|
|
120
|
+
# is local-only and never committed to the repo.
|
|
121
|
+
def swap_plus_minus_for_transactions
|
|
122
|
+
sign_flip_rules.any? do |rule|
|
|
123
|
+
(rule.fin_year.nil? || rule.fin_year == fin_year) &&
|
|
124
|
+
rule.account_number == account_number &&
|
|
125
|
+
rule.coa_codes.include?(coa_code)
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
private
|
|
130
|
+
|
|
131
|
+
def sign_flip_rules
|
|
132
|
+
Appydave::Tools::Configuration::Config.bank_reconciliation.sign_flip_rules
|
|
133
|
+
rescue StandardError
|
|
134
|
+
[]
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def parse_date(date_string)
|
|
138
|
+
formats = ['%d/%m/%Y', '%Y-%m-%d %H:%M:%S']
|
|
139
|
+
formats.each do |format|
|
|
140
|
+
return Date.strptime(date_string, format)
|
|
141
|
+
rescue Date::Error
|
|
142
|
+
next
|
|
143
|
+
end
|
|
144
|
+
raise Date::Error, "Invalid date format: #{date_string}"
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def determine_fin_year(date)
|
|
148
|
+
if date.month >= 7
|
|
149
|
+
"#{date.year}-#{date.year + 1}"
|
|
150
|
+
else
|
|
151
|
+
"#{date.year - 1}-#{date.year}"
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
end
|
|
@@ -34,11 +34,15 @@ module Appydave
|
|
|
34
34
|
@tokens = false
|
|
35
35
|
@base_dir = Dir.pwd
|
|
36
36
|
|
|
37
|
-
|
|
37
|
+
configured_omi = read_setting('omi-directory-path')
|
|
38
|
+
@omi_dir = configured_omi || File.expand_path('~/dev/raw-intake/omi')
|
|
38
39
|
end
|
|
39
40
|
|
|
40
41
|
def brains_root
|
|
41
|
-
@brains_root ||=
|
|
42
|
+
@brains_root ||= begin
|
|
43
|
+
configured = read_setting('brains-root-path')
|
|
44
|
+
configured || File.expand_path('~/dev/ad/brains')
|
|
45
|
+
end
|
|
42
46
|
end
|
|
43
47
|
|
|
44
48
|
def brains_index_path
|
|
@@ -52,6 +56,19 @@ module Appydave
|
|
|
52
56
|
def omi_query?
|
|
53
57
|
omi
|
|
54
58
|
end
|
|
59
|
+
|
|
60
|
+
private
|
|
61
|
+
|
|
62
|
+
# Read a path value from settings config, expanding ~ if set.
|
|
63
|
+
# Returns nil if config is unavailable or key is absent/blank.
|
|
64
|
+
def read_setting(key)
|
|
65
|
+
value = Appydave::Tools::Configuration::Config.settings.get(key)
|
|
66
|
+
return nil if value.nil? || value.to_s.strip.empty?
|
|
67
|
+
|
|
68
|
+
File.expand_path(value)
|
|
69
|
+
rescue StandardError
|
|
70
|
+
nil
|
|
71
|
+
end
|
|
55
72
|
end
|
|
56
73
|
end
|
|
57
74
|
end
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Appydave
|
|
4
|
+
module Tools
|
|
5
|
+
module Configuration
|
|
6
|
+
# Installs bundled example configuration files into the user's config directory.
|
|
7
|
+
#
|
|
8
|
+
# Example files live at config/examples/*.example.json inside the gem.
|
|
9
|
+
# Each file is installed without the `.example` segment in its name so that
|
|
10
|
+
# `settings.example.json` becomes `settings.json` in the target directory.
|
|
11
|
+
#
|
|
12
|
+
# Files are never overwritten — existing files are skipped and reported.
|
|
13
|
+
#
|
|
14
|
+
# @example Install all examples
|
|
15
|
+
# result = ExampleInstaller.new.install
|
|
16
|
+
# result[:installed] #=> ["settings.json", "locations.json"]
|
|
17
|
+
# result[:skipped] #=> []
|
|
18
|
+
class ExampleInstaller
|
|
19
|
+
EXAMPLES_PATH = File.expand_path('../../../../config/examples', __dir__)
|
|
20
|
+
|
|
21
|
+
# @param target_path [String, nil] Directory to install into.
|
|
22
|
+
# Defaults to the active Config.config_path (~/.config/appydave).
|
|
23
|
+
def initialize(target_path: nil)
|
|
24
|
+
@target_path = target_path || Config.config_path
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Install all bundled example files that do not yet exist.
|
|
28
|
+
#
|
|
29
|
+
# @return [Hash] with keys :installed (Array<String>) and :skipped (Array<String>)
|
|
30
|
+
def install
|
|
31
|
+
FileUtils.mkdir_p(@target_path)
|
|
32
|
+
results = { installed: [], skipped: [] }
|
|
33
|
+
|
|
34
|
+
example_files.each do |src|
|
|
35
|
+
dest = destination_for(src)
|
|
36
|
+
basename = File.basename(dest)
|
|
37
|
+
|
|
38
|
+
if File.exist?(dest)
|
|
39
|
+
results[:skipped] << basename
|
|
40
|
+
else
|
|
41
|
+
FileUtils.cp(src, dest)
|
|
42
|
+
results[:installed] << basename
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
results
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# List the filenames that would be installed (target names, not source names).
|
|
50
|
+
#
|
|
51
|
+
# @return [Array<String>]
|
|
52
|
+
def available
|
|
53
|
+
example_files.map { |f| target_name(f) }
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
private
|
|
57
|
+
|
|
58
|
+
def example_files
|
|
59
|
+
Dir.glob(File.join(EXAMPLES_PATH, '*.example.*')).sort
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def destination_for(src)
|
|
63
|
+
File.join(@target_path, target_name(src))
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def target_name(src)
|
|
67
|
+
File.basename(src).sub('.example', '')
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Appydave
|
|
4
|
+
module Tools
|
|
5
|
+
module Configuration
|
|
6
|
+
module Models
|
|
7
|
+
# Bank reconciliation configuration
|
|
8
|
+
class BankReconciliationConfig < ConfigBase
|
|
9
|
+
# def
|
|
10
|
+
# Retrieve all bank accounts
|
|
11
|
+
def bank_accounts
|
|
12
|
+
data['bank_accounts'].map do |account|
|
|
13
|
+
BankAccount.new(account)
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def chart_of_accounts
|
|
18
|
+
data['chart_of_accounts'].map do |entry|
|
|
19
|
+
ChartOfAccount.new(entry)
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Sign-flip rules — per-account, per-fin-year, per-COA exceptions where the
|
|
24
|
+
# raw bank-export sign is wrong and needs flipping during clean_amount.
|
|
25
|
+
# Personal account numbers belong here (in config), not in source.
|
|
26
|
+
def sign_flip_rules
|
|
27
|
+
(data['sign_flip_rules'] || []).map do |rule|
|
|
28
|
+
SignFlipRule.new(rule)
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def get_bank_account(account_number, bsb = nil)
|
|
33
|
+
normalized_bsb = bsb.to_s.empty? ? nil : bsb
|
|
34
|
+
account_data = data['bank_accounts'].find do |account|
|
|
35
|
+
acct_bsb = account['bsb'].to_s.empty? ? nil : account['bsb']
|
|
36
|
+
account['account_number'] == account_number && (normalized_bsb.nil? || acct_bsb == normalized_bsb)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
BankAccount.new(account_data) if account_data
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Retrieve a chart of account entry by code
|
|
43
|
+
def get_chart_of_account(code)
|
|
44
|
+
entry_data = data['chart_of_accounts'].find { |entry| entry['code'] == code }
|
|
45
|
+
ChartOfAccount.new(entry_data) if entry_data
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def print
|
|
49
|
+
log.subheading 'Bank Reconciliation - Accounts'
|
|
50
|
+
|
|
51
|
+
tp bank_accounts, :account_number, :bsb, :name, :bank
|
|
52
|
+
|
|
53
|
+
log.subheading 'Bank Reconciliation - Chart of Accounts'
|
|
54
|
+
|
|
55
|
+
tp chart_of_accounts, :code, :narration
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def coa_to_csv
|
|
59
|
+
csv_file_path = File.join(Config.config_path, 'bank_reconciliation.chart_of_accounts.csv')
|
|
60
|
+
|
|
61
|
+
CSV.open(csv_file_path, 'w') do |csv|
|
|
62
|
+
csv << %w[code narration]
|
|
63
|
+
|
|
64
|
+
chart_of_accounts.sort_by(&:code).each do |entry|
|
|
65
|
+
csv << [entry.code, entry.narration]
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def coa_csv_to_json
|
|
71
|
+
csv_file_path = File.join(Config.config_path, 'bank_reconciliation.chart_of_accounts.csv')
|
|
72
|
+
|
|
73
|
+
coa_data = CSV.read(csv_file_path, headers: true).map(&:to_h)
|
|
74
|
+
|
|
75
|
+
data['chart_of_accounts'] = coa_data
|
|
76
|
+
|
|
77
|
+
save
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
private
|
|
81
|
+
|
|
82
|
+
def default_data
|
|
83
|
+
{
|
|
84
|
+
'bank_accounts' => [],
|
|
85
|
+
'chart_of_accounts' => [],
|
|
86
|
+
'sign_flip_rules' => []
|
|
87
|
+
}
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Inner class to represent a bank account
|
|
91
|
+
class BankAccount
|
|
92
|
+
attr_accessor :account_number, :bsb, :name, :platform
|
|
93
|
+
|
|
94
|
+
def initialize(data)
|
|
95
|
+
@account_number = data['account_number']
|
|
96
|
+
@bsb = data['bsb']
|
|
97
|
+
@name = data['name']
|
|
98
|
+
@platform = data['platform']
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def to_h
|
|
102
|
+
{
|
|
103
|
+
'account_number' => @account_number,
|
|
104
|
+
'bsb' => @bsb,
|
|
105
|
+
'name' => @name,
|
|
106
|
+
'platform' => @platform
|
|
107
|
+
}
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Inner class to represent a chart of account entry
|
|
112
|
+
class ChartOfAccount
|
|
113
|
+
attr_accessor :code, :narration
|
|
114
|
+
|
|
115
|
+
def initialize(data)
|
|
116
|
+
@code = data['code']
|
|
117
|
+
@narration = data['narration']
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def to_h
|
|
121
|
+
{
|
|
122
|
+
'code' => @code,
|
|
123
|
+
'narration' => @narration
|
|
124
|
+
}
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# Inner class to represent a sign-flip rule.
|
|
129
|
+
# Matches a transaction by (optional fin_year, account_number, coa_code).
|
|
130
|
+
# When nil, fin_year matches any year.
|
|
131
|
+
class SignFlipRule
|
|
132
|
+
attr_accessor :fin_year, :account_number, :coa_codes
|
|
133
|
+
|
|
134
|
+
def initialize(data)
|
|
135
|
+
@fin_year = data['fin_year']
|
|
136
|
+
@account_number = data['account_number']
|
|
137
|
+
@coa_codes = data['coa_codes'] || []
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def to_h
|
|
141
|
+
{
|
|
142
|
+
'fin_year' => @fin_year,
|
|
143
|
+
'account_number' => @account_number,
|
|
144
|
+
'coa_codes' => @coa_codes
|
|
145
|
+
}
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
end
|
|
@@ -42,6 +42,18 @@ module Appydave
|
|
|
42
42
|
get('current_user')
|
|
43
43
|
end
|
|
44
44
|
|
|
45
|
+
# Path to the root brains directory (second-brain knowledge base)
|
|
46
|
+
# Configure via settings.json key: brains-root-path
|
|
47
|
+
def brains_root_path
|
|
48
|
+
get('brains-root-path')
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Path to the OMI wearable transcripts directory
|
|
52
|
+
# Configure via settings.json key: omi-directory-path
|
|
53
|
+
def omi_directory_path
|
|
54
|
+
get('omi-directory-path')
|
|
55
|
+
end
|
|
56
|
+
|
|
45
57
|
def print
|
|
46
58
|
log.subheading 'Settings Configuration'
|
|
47
59
|
|