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.
Files changed (35) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +15 -0
  3. data/CLAUDE.md +1 -1
  4. data/CONTEXT.md +133 -23
  5. data/bin/configuration.rb +20 -0
  6. data/bin/query_apps.rb +74 -0
  7. data/config/examples/locations.example.json +48 -0
  8. data/config/examples/settings.example.json +9 -0
  9. data/config/random-queries.yml +24 -45
  10. data/context.globs.json +24 -0
  11. data/docs/guides/tools/dam/dam-usage.md +261 -471
  12. data/docs/planning/multi-user-support.md +108 -0
  13. data/docs/planning/query-apps-design.md +344 -0
  14. data/docs/planning/query-skills-plan.md +354 -0
  15. data/docs/specs/jump-add-display-fix.md +249 -0
  16. data/exe/query_apps +7 -0
  17. data/lib/appydave/tools/app_context/app_finder.rb +176 -0
  18. data/lib/appydave/tools/app_context/globs_loader.rb +116 -0
  19. data/lib/appydave/tools/app_context/options.rb +28 -0
  20. data/lib/appydave/tools/bank_reconciliation/_doc.md +36 -0
  21. data/lib/appydave/tools/bank_reconciliation/clean/clean_transactions.rb +159 -0
  22. data/lib/appydave/tools/bank_reconciliation/clean/mapper.rb +133 -0
  23. data/lib/appydave/tools/bank_reconciliation/clean/read_transactions.rb +258 -0
  24. data/lib/appydave/tools/bank_reconciliation/models/transaction.rb +158 -0
  25. data/lib/appydave/tools/brain_context/options.rb +19 -2
  26. data/lib/appydave/tools/configuration/example_installer.rb +72 -0
  27. data/lib/appydave/tools/configuration/models/bank_reconciliation_config.rb +152 -0
  28. data/lib/appydave/tools/configuration/models/settings_config.rb +12 -0
  29. data/lib/appydave/tools/jump/formatters/table_formatter.rb +16 -5
  30. data/lib/appydave/tools/version.rb +1 -1
  31. data/lib/appydave/tools/zsh_history/config.rb +2 -2
  32. data/lib/appydave/tools/zsh_history/filter.rb +10 -4
  33. data/lib/appydave/tools.rb +12 -0
  34. data/package.json +1 -1
  35. 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
- @omi_dir = File.expand_path('~/dev/raw-intake/omi')
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 ||= File.expand_path('~/dev/ad/brains')
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