mercury_banking 0.5.34

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,406 @@
1
+ module MercuryBanking
2
+ module CLI
3
+ # Module for reconciliation-related commands
4
+ module Reconciliation
5
+ # Add reconciliation-related commands to the CLI class
6
+ def self.included(base)
7
+ base.class_eval do
8
+ desc 'reconcile ACCOUNT_ID_OR_NUMBER TRANSACTION_ID', 'Mark a transaction as reconciled'
9
+ def reconcile(account_identifier, transaction_id)
10
+ with_api_client do |client|
11
+ # Determine if we're dealing with an account ID or account number
12
+ account_id = nil
13
+ if account_identifier.match?(/^\d+$/) && !account_identifier.include?('-')
14
+ begin
15
+ account = client.find_account_by_number(account_identifier)
16
+ account_id = account["id"]
17
+ rescue => e
18
+ # If not found by number, assume it's an ID
19
+ account_id = account_identifier
20
+ account = client.get_account(account_id)
21
+ end
22
+ else
23
+ account_id = account_identifier
24
+ account = client.get_account(account_id)
25
+ end
26
+
27
+ # Get the transaction to verify it exists
28
+ transaction = client.transaction(account_id, transaction_id)
29
+
30
+ # Mark the transaction as reconciled
31
+ reconciliation = MercuryBanking::Reconciliation.new
32
+ result = reconciliation.mark_reconciled(account_id, transaction_id)
33
+
34
+ if options[:json]
35
+ puts JSON.pretty_generate({
36
+ 'account_id' => account_id,
37
+ 'transaction_id' => transaction_id,
38
+ 'reconciled' => true,
39
+ 'already_reconciled' => !result
40
+ })
41
+ else
42
+ if result
43
+ puts "Transaction #{transaction_id} marked as reconciled."
44
+ else
45
+ puts "Transaction #{transaction_id} was already reconciled."
46
+ end
47
+ end
48
+ end
49
+ end
50
+
51
+ desc 'unreconcile ACCOUNT_ID_OR_NUMBER TRANSACTION_ID', 'Mark a transaction as unreconciled'
52
+ def unreconcile(account_identifier, transaction_id)
53
+ with_api_client do |client|
54
+ # Determine if we're dealing with an account ID or account number
55
+ account_id = nil
56
+ if account_identifier.match?(/^\d+$/) && !account_identifier.include?('-')
57
+ begin
58
+ account = client.find_account_by_number(account_identifier)
59
+ account_id = account["id"]
60
+ rescue => e
61
+ # If not found by number, assume it's an ID
62
+ account_id = account_identifier
63
+ account = client.get_account(account_id)
64
+ end
65
+ else
66
+ account_id = account_identifier
67
+ account = client.get_account(account_id)
68
+ end
69
+
70
+ # Get the transaction to verify it exists
71
+ transaction = client.transaction(account_id, transaction_id)
72
+
73
+ # Mark the transaction as unreconciled
74
+ reconciliation = MercuryBanking::Reconciliation.new
75
+ result = reconciliation.mark_unreconciled(account_id, transaction_id)
76
+
77
+ if options[:json]
78
+ puts JSON.pretty_generate({
79
+ 'account_id' => account_id,
80
+ 'transaction_id' => transaction_id,
81
+ 'reconciled' => false,
82
+ 'was_reconciled' => result
83
+ })
84
+ else
85
+ if result
86
+ puts "Transaction #{transaction_id} marked as unreconciled."
87
+ else
88
+ puts "Transaction #{transaction_id} was not reconciled."
89
+ end
90
+ end
91
+ end
92
+ end
93
+
94
+ desc 'reconciliation_status ACCOUNT_ID_OR_NUMBER', 'Show reconciliation status for an account'
95
+ method_option :start, type: :string, default: '2020-01-01', desc: 'Start date for transactions (YYYY-MM-DD)'
96
+ method_option :end, type: :string, desc: 'End date for transactions (YYYY-MM-DD)'
97
+ method_option :format, type: :string, default: 'table', enum: ['table', 'json'], desc: 'Output format (table or json)'
98
+ def reconciliation_status(account_identifier)
99
+ with_api_client do |client|
100
+ # Determine if we're dealing with an account ID or account number
101
+ account_id = nil
102
+ if account_identifier.match?(/^\d+$/) && !account_identifier.include?('-')
103
+ begin
104
+ account = client.find_account_by_number(account_identifier)
105
+ account_id = account["id"]
106
+ rescue => e
107
+ # If not found by number, assume it's an ID
108
+ account_id = account_identifier
109
+ account = client.get_account(account_id)
110
+ end
111
+ else
112
+ account_id = account_identifier
113
+ account = client.get_account(account_id)
114
+ end
115
+
116
+ # Get transactions for the account
117
+ start_date = options[:start]
118
+ end_date = options[:end]
119
+
120
+ transactions = client.get_transactions(account_id, start_date)
121
+
122
+ # Filter by end date if specified
123
+ if end_date
124
+ end_date_obj = Date.parse(end_date)
125
+ transactions = transactions.select do |t|
126
+ transaction_date = t["postedAt"] ? Date.parse(t["postedAt"]) : Date.parse(t["createdAt"])
127
+ transaction_date <= end_date_obj
128
+ end
129
+ end
130
+
131
+ # Get reconciliation status
132
+ reconciliation = MercuryBanking::Reconciliation.new
133
+ status = reconciliation.get_reconciliation_status(account_id, transactions)
134
+ summary = reconciliation.get_reconciliation_summary(account_id, transactions)
135
+
136
+ if options[:json] || options[:format] == 'json'
137
+ puts JSON.pretty_generate({
138
+ 'account_id' => account_id,
139
+ 'account_name' => account['name'],
140
+ 'summary' => summary,
141
+ 'transactions' => status
142
+ })
143
+ else
144
+ # Display summary
145
+ puts "Reconciliation Status for #{account['name']} (#{account['accountNumber']})"
146
+ puts "Period: #{start_date} to #{end_date || 'present'}"
147
+ puts
148
+ puts "Summary:"
149
+ puts "- Total Transactions: #{summary[:total_transactions]}"
150
+ puts "- Reconciled: #{summary[:reconciled_count]} ($#{format("%.2f", summary[:reconciled_amount])})"
151
+ puts "- Unreconciled: #{summary[:unreconciled_count]} ($#{format("%.2f", summary[:unreconciled_amount])})"
152
+ puts
153
+
154
+ # Display transactions
155
+ rows = status.map do |t|
156
+ date = t[:date] ? Time.parse(t[:date]).strftime("%Y-%m-%d") : "Unknown"
157
+ description = t[:description].length > 30 ? "#{t[:description][0..27]}..." : t[:description]
158
+ amount = format("$%.2f", t[:amount])
159
+ reconciled = t[:reconciled] ? "✓" : " "
160
+ reconciled_at = t[:reconciled_at] || ""
161
+
162
+ [t[:transaction_id], date, description, amount, reconciled, reconciled_at]
163
+ end
164
+
165
+ table = ::Terminal::Table.new(
166
+ headings: ['Transaction ID', 'Date', 'Description', 'Amount', 'Reconciled', 'Reconciled At'],
167
+ rows: rows
168
+ )
169
+
170
+ puts table
171
+ end
172
+ end
173
+ end
174
+
175
+ desc 'reconcile_all ACCOUNT_ID_OR_NUMBER', 'Mark all transactions as reconciled up to a specified date'
176
+ method_option :date, type: :string, desc: 'Date up to which to reconcile transactions (YYYY-MM-DD). Defaults to current date.'
177
+ method_option :start, type: :string, default: '2020-01-01', desc: 'Start date for transactions (YYYY-MM-DD)'
178
+ method_option :dry_run, type: :boolean, default: false, desc: 'Show what would be reconciled without making changes'
179
+ method_option :json, type: :boolean, default: false, desc: 'Output in JSON format'
180
+ method_option :debug, type: :boolean, default: false, desc: 'Show debug information'
181
+ def reconcile_all(account_identifier)
182
+ with_api_client do |client|
183
+ # Determine if we're dealing with an account ID or account number
184
+ account_id = nil
185
+ if account_identifier.match?(/^\d+$/) && !account_identifier.include?('-')
186
+ begin
187
+ account = client.find_account_by_number(account_identifier)
188
+ account_id = account["id"]
189
+ rescue => e
190
+ # If not found by number, assume it's an ID
191
+ account_id = account_identifier
192
+ account = client.get_account(account_id)
193
+ end
194
+ else
195
+ account_id = account_identifier
196
+ account = client.get_account(account_id)
197
+ end
198
+
199
+ # Get transactions for the account
200
+ start_date = options[:start]
201
+ puts "Fetching transactions for account #{account['name']} (#{account['accountNumber']}) since #{start_date}..." if options[:debug]
202
+ transactions = client.get_transactions(account_id, start_date)
203
+ puts "Found #{transactions.size} total transactions" if options[:debug]
204
+
205
+ # Filter by date if specified
206
+ cutoff_date = options[:date] ? Date.parse(options[:date]) : Date.today
207
+ puts "Using cutoff date: #{cutoff_date}" if options[:debug]
208
+
209
+ # Filter transactions that are on or before the cutoff date
210
+ transactions_to_reconcile = transactions.select do |t|
211
+ transaction_date = t["postedAt"] ? Date.parse(t["postedAt"]) : Date.parse(t["createdAt"])
212
+ transaction_date <= cutoff_date
213
+ end
214
+ puts "Found #{transactions_to_reconcile.size} transactions on or before cutoff date" if options[:debug]
215
+
216
+ if transactions_to_reconcile.empty?
217
+ if options[:json]
218
+ puts JSON.pretty_generate({
219
+ 'account_id' => account_id,
220
+ 'account_name' => account['name'],
221
+ 'transactions_reconciled' => 0,
222
+ 'message' => "No transactions found to reconcile."
223
+ })
224
+ else
225
+ puts "No transactions found to reconcile for #{account['name']} (#{account['accountNumber']})."
226
+ end
227
+ return
228
+ end
229
+
230
+ # Get already reconciled transactions
231
+ reconciliation = MercuryBanking::Reconciliation.new
232
+ reconciled_ids = reconciliation.get_reconciled_transactions(account_id)
233
+ puts "Found #{reconciled_ids.size} already reconciled transactions" if options[:debug]
234
+
235
+ # Filter out already reconciled transactions
236
+ transactions_to_reconcile = transactions_to_reconcile.reject { |t| reconciled_ids.include?(t["id"]) }
237
+ puts "Found #{transactions_to_reconcile.size} transactions to reconcile" if options[:debug]
238
+
239
+ if transactions_to_reconcile.empty?
240
+ if options[:json]
241
+ puts JSON.pretty_generate({
242
+ 'account_id' => account_id,
243
+ 'account_name' => account['name'],
244
+ 'transactions_reconciled' => 0,
245
+ 'message' => "All transactions are already reconciled up to #{cutoff_date}."
246
+ })
247
+ else
248
+ puts "All transactions are already reconciled up to #{cutoff_date} for #{account['name']} (#{account['accountNumber']})."
249
+ end
250
+ return
251
+ end
252
+
253
+ # Display what will be reconciled
254
+ if options[:json]
255
+ transactions_data = transactions_to_reconcile.map do |t|
256
+ {
257
+ 'id' => t["id"],
258
+ 'date' => t["postedAt"] || t["createdAt"],
259
+ 'description' => t["bankDescription"] || t["externalMemo"] || "Unknown transaction",
260
+ 'amount' => t["amount"]
261
+ }
262
+ end
263
+
264
+ puts JSON.pretty_generate({
265
+ 'account_id' => account_id,
266
+ 'account_name' => account['name'],
267
+ 'cutoff_date' => cutoff_date.to_s,
268
+ 'transactions_to_reconcile' => transactions_data,
269
+ 'dry_run' => options[:dry_run]
270
+ })
271
+ else
272
+ puts "Reconciliation for #{account['name']} (#{account['accountNumber']})"
273
+ puts "Transactions to reconcile up to #{cutoff_date}:"
274
+ puts
275
+
276
+ total_amount = 0
277
+
278
+ rows = transactions_to_reconcile.map do |t|
279
+ date = t["postedAt"] ? Time.parse(t["postedAt"]).strftime("%Y-%m-%d") : Time.parse(t["createdAt"]).strftime("%Y-%m-%d")
280
+ description = t["bankDescription"] || t["externalMemo"] || "Unknown transaction"
281
+ description = description.length > 30 ? "#{description[0..27]}..." : description
282
+ amount = t["amount"]
283
+ total_amount += amount
284
+
285
+ [t["id"], date, description, format("$%.2f", amount)]
286
+ end
287
+
288
+ table = ::Terminal::Table.new(
289
+ headings: ['Transaction ID', 'Date', 'Description', 'Amount'],
290
+ rows: rows
291
+ )
292
+
293
+ puts table
294
+ puts
295
+ puts "Total transactions: #{transactions_to_reconcile.size}"
296
+ puts "Total amount: #{format("$%.2f", total_amount)}"
297
+ puts
298
+ puts "This is a #{options[:dry_run] ? 'DRY RUN' : 'LIVE RUN'}"
299
+ end
300
+
301
+ # Mark transactions as reconciled if not a dry run
302
+ unless options[:dry_run]
303
+ reconciled_count = 0
304
+
305
+ transactions_to_reconcile.each do |t|
306
+ result = reconciliation.mark_reconciled(account_id, t["id"])
307
+ reconciled_count += 1 if result
308
+ end
309
+
310
+ if options[:json]
311
+ puts JSON.pretty_generate({
312
+ 'account_id' => account_id,
313
+ 'account_name' => account['name'],
314
+ 'transactions_reconciled' => reconciled_count,
315
+ 'message' => "Successfully reconciled #{reconciled_count} transactions."
316
+ })
317
+ else
318
+ puts "Successfully reconciled #{reconciled_count} transactions."
319
+ end
320
+ else
321
+ if !options[:json]
322
+ puts "Dry run completed. No transactions were reconciled."
323
+ puts "Run without --dry-run to reconcile these transactions."
324
+ end
325
+ end
326
+ end
327
+ end
328
+
329
+ desc 'export_reconciled ACCOUNT_ID_OR_NUMBER', 'Export reconciled transactions to a ledger file'
330
+ method_option :start, type: :string, default: '2020-01-01', desc: 'Start date for transactions (YYYY-MM-DD)'
331
+ method_option :end, type: :string, desc: 'End date for transactions (YYYY-MM-DD)'
332
+ method_option :format, type: :string, default: 'ledger', enum: ['ledger', 'beancount', 'hledger', 'csv'], desc: 'Export format'
333
+ method_option :output, type: :string, desc: 'Output file path (defaults to reconciled_transactions.<format>)'
334
+ method_option :all, type: :boolean, default: false, desc: 'Export all transactions with reconciliation status'
335
+ def export_reconciled(account_identifier)
336
+ with_api_client do |client|
337
+ # Determine if we're dealing with an account ID or account number
338
+ account_id = nil
339
+ if account_identifier.match?(/^\d+$/) && !account_identifier.include?('-')
340
+ begin
341
+ account = client.find_account_by_number(account_identifier)
342
+ account_id = account["id"]
343
+ rescue => e
344
+ # If not found by number, assume it's an ID
345
+ account_id = account_identifier
346
+ account = client.get_account(account_id)
347
+ end
348
+ else
349
+ account_id = account_identifier
350
+ account = client.get_account(account_id)
351
+ end
352
+
353
+ # Get transactions for the account
354
+ start_date = options[:start]
355
+ end_date = options[:end]
356
+ format = options[:format]
357
+ export_all = options[:all]
358
+
359
+ transactions = client.get_transactions(account_id, start_date)
360
+
361
+ # Filter by end date if specified
362
+ if end_date
363
+ end_date_obj = Date.parse(end_date)
364
+ transactions = transactions.select do |t|
365
+ transaction_date = t["postedAt"] ? Date.parse(t["postedAt"]) : Date.parse(t["createdAt"])
366
+ transaction_date <= end_date_obj
367
+ end
368
+ end
369
+
370
+ # Get reconciled transactions
371
+ reconciliation = MercuryBanking::Reconciliation.new
372
+ reconciled_ids = reconciliation.get_reconciled_transactions(account_id)
373
+
374
+ # Filter transactions if not exporting all
375
+ unless export_all
376
+ transactions = transactions.select { |t| reconciled_ids.include?(t["id"]) }
377
+ end
378
+
379
+ if transactions.empty?
380
+ puts "No #{export_all ? '' : 'reconciled '}transactions found to export."
381
+ return
382
+ end
383
+
384
+ # Determine output file path
385
+ output_file = options[:output] || "reconciled_transactions.#{format}"
386
+
387
+ # Export transactions in the specified format
388
+ case format
389
+ when 'ledger'
390
+ export_to_ledger(transactions, output_file, reconciled_ids, false)
391
+ when 'beancount'
392
+ export_to_beancount(transactions, output_file, reconciled_ids, false)
393
+ when 'hledger'
394
+ export_to_hledger(transactions, output_file, reconciled_ids)
395
+ when 'csv'
396
+ export_to_csv(transactions, output_file, reconciled_ids)
397
+ end
398
+
399
+ puts "Exported #{transactions.count} transaction(s) to #{output_file} in #{format} format."
400
+ end
401
+ end
402
+ end
403
+ end
404
+ end
405
+ end
406
+ end
@@ -0,0 +1,265 @@
1
+ module MercuryBanking
2
+ module CLI
3
+ # Module for report-related commands
4
+ module Reports
5
+ # Add report-related commands to the CLI class
6
+ def self.included(base)
7
+ base.class_eval do
8
+ # Methods that should not be exposed as commands
9
+ no_commands do
10
+ # The balance_sheet method has been moved to the Financials module
11
+ end
12
+
13
+ desc 'statement ACCOUNT_NAME', 'Generate a statement for a specific account and period'
14
+ method_option :start, type: :string, default: '2020-01-01', desc: 'Start date for statement (YYYY-MM-DD)'
15
+ method_option :end, type: :string, desc: 'End date for statement (YYYY-MM-DD)'
16
+ method_option :format, type: :string, default: 'text', enum: ['text', 'json', 'csv'], desc: 'Output format'
17
+ method_option :save, type: :string, desc: 'Save statement to specified file'
18
+ method_option :debug, type: :boolean, default: false, desc: 'Show debug information about balance calculation'
19
+ def statement(account_name)
20
+ with_api_client do |client|
21
+ start_date = options[:start]
22
+ end_date = options[:end]
23
+ format = options[:format]
24
+ debug = options[:debug]
25
+
26
+ # Find the account by name
27
+ accounts = client.accounts
28
+ account = accounts.find { |a| a["name"] == account_name }
29
+
30
+ unless account
31
+ puts "Error: Account '#{account_name}' not found. Available accounts:"
32
+ accounts.each { |a| puts "- #{a['name']}" }
33
+ return
34
+ end
35
+
36
+ # Get transactions for the account
37
+ transactions = client.get_transactions(account["id"], start_date)
38
+
39
+ # Filter by end date if specified
40
+ if end_date
41
+ end_date_obj = Date.parse(end_date)
42
+ transactions = transactions.select do |t|
43
+ transaction_date = t["postedAt"] ? Date.parse(t["postedAt"]) : Date.parse(t["createdAt"])
44
+ transaction_date <= end_date_obj
45
+ end
46
+ end
47
+
48
+ if transactions.empty?
49
+ puts "No transactions found for the specified period."
50
+ return
51
+ end
52
+
53
+ # Sort transactions by date
54
+ transactions = transactions.sort_by do |t|
55
+ date = t["postedAt"] || t["createdAt"]
56
+ Time.parse(date)
57
+ end
58
+
59
+ # Calculate opening and closing balances
60
+ current_balance = account["currentBalance"]
61
+ closing_balance = current_balance
62
+
63
+ if debug
64
+ puts "Debug: Current balance from Mercury API: $#{format("%.2f", current_balance)}"
65
+ end
66
+
67
+ # Subtract all transactions that occurred after the end date
68
+ if end_date
69
+ end_date_obj = Date.parse(end_date)
70
+ after_end_date_transactions = transactions.select do |t|
71
+ transaction_date = t["postedAt"] ? Date.parse(t["postedAt"]) : Date.parse(t["createdAt"])
72
+ transaction_date > end_date_obj
73
+ end
74
+
75
+ if debug && after_end_date_transactions.any?
76
+ puts "Debug: Transactions after end date (#{end_date}):"
77
+ after_end_date_transactions.each do |t|
78
+ date = t["postedAt"] ? Time.parse(t["postedAt"]).strftime("%Y-%m-%d") : Time.parse(t["createdAt"]).strftime("%Y-%m-%d")
79
+ puts " #{date}: $#{format("%.2f", t["amount"])}"
80
+ end
81
+ end
82
+
83
+ after_end_date_sum = after_end_date_transactions.sum { |t| t["amount"] }
84
+ closing_balance = current_balance - after_end_date_sum
85
+
86
+ if debug
87
+ puts "Debug: Sum of transactions after end date: $#{format("%.2f", after_end_date_sum)}"
88
+ puts "Debug: Closing balance at end date: $#{format("%.2f", closing_balance)}"
89
+ end
90
+ else
91
+ if debug
92
+ puts "Debug: No end date specified, closing balance equals current balance"
93
+ end
94
+ end
95
+
96
+ # Calculate opening balance by subtracting all transactions in the period
97
+ opening_balance = closing_balance
98
+
99
+ if debug
100
+ puts "Debug: Transactions in the statement period:"
101
+ end
102
+
103
+ transactions_sum = 0
104
+ transactions.each do |t|
105
+ amount = t["amount"]
106
+ transactions_sum += amount
107
+
108
+ if debug
109
+ date = t["postedAt"] ? Time.parse(t["postedAt"]).strftime("%Y-%m-%d") : Time.parse(t["createdAt"]).strftime("%Y-%m-%d")
110
+ puts " #{date}: $#{format("%.2f", amount)}"
111
+ end
112
+
113
+ opening_balance -= amount
114
+ end
115
+
116
+ if debug
117
+ puts "Debug: Sum of transactions in period: $#{format("%.2f", transactions_sum)}"
118
+ puts "Debug: Opening balance calculation: $#{format("%.2f", closing_balance)} - $#{format("%.2f", transactions_sum)} = $#{format("%.2f", opening_balance)}"
119
+ end
120
+
121
+ # Generate statement based on format
122
+ case format
123
+ when 'text'
124
+ generate_text_statement(account, transactions, opening_balance, closing_balance, start_date, end_date, options[:save], debug)
125
+ when 'json'
126
+ generate_json_statement(account, transactions, opening_balance, closing_balance, start_date, end_date, options[:save])
127
+ when 'csv'
128
+ generate_csv_statement(account, transactions, opening_balance, closing_balance, start_date, end_date, options[:save])
129
+ end
130
+ end
131
+ end
132
+
133
+ private
134
+
135
+ def generate_text_statement(account, transactions, opening_balance, closing_balance, start_date, end_date, save_path, debug = false)
136
+ # Create statement header
137
+ statement = []
138
+ statement << "Mercury Banking Statement"
139
+ statement << "Account: #{account['name']} (#{account['accountNumber']})"
140
+ statement << "Period: #{start_date} to #{end_date || 'present'}"
141
+ statement << "Generated on: #{Time.now.strftime('%Y-%m-%d %H:%M:%S')}"
142
+ statement << ""
143
+ statement << "Opening Balance: $#{format("%.2f", opening_balance)}"
144
+ statement << ""
145
+ statement << "Transactions:"
146
+ statement << "-" * 80
147
+ statement << "Date".ljust(12) + "Description".ljust(40) + "Amount".ljust(15) + "Balance"
148
+ statement << "-" * 80
149
+
150
+ # Add transactions
151
+ running_balance = opening_balance
152
+ transactions.each do |t|
153
+ date = t["postedAt"] ? Time.parse(t["postedAt"]).strftime("%Y-%m-%d") : Time.parse(t["createdAt"]).strftime("%Y-%m-%d")
154
+ description = t["bankDescription"] || t["externalMemo"] || "Unknown transaction"
155
+ description = description.length > 37 ? "#{description[0..34]}..." : description
156
+ amount = t["amount"]
157
+ running_balance += amount
158
+
159
+ statement << date.ljust(12) + description.ljust(40) + format("$%.2f", amount).rjust(15) + format("$%.2f", running_balance).rjust(15)
160
+ end
161
+
162
+ statement << "-" * 80
163
+ statement << "Closing Balance: $#{format("%.2f", closing_balance)}"
164
+
165
+ if debug
166
+ statement << ""
167
+ statement << "Debug Information:"
168
+ statement << "- Opening balance is calculated by taking the closing balance and subtracting all transactions in the period"
169
+ statement << "- Closing balance is the current balance adjusted for any transactions after the end date"
170
+ statement << "- Running balance starts with the opening balance and adds each transaction amount"
171
+ end
172
+
173
+ # Output or save
174
+ if save_path
175
+ File.write(save_path, statement.join("\n"))
176
+ puts "Statement saved to #{save_path}"
177
+ else
178
+ puts statement.join("\n")
179
+ end
180
+ end
181
+
182
+ def generate_json_statement(account, transactions, opening_balance, closing_balance, start_date, end_date, save_path)
183
+ # Format transactions
184
+ formatted_transactions = transactions.map do |t|
185
+ {
186
+ date: t["postedAt"] || t["createdAt"],
187
+ description: t["bankDescription"] || t["externalMemo"] || "Unknown transaction",
188
+ amount: t["amount"],
189
+ status: t["status"]
190
+ }
191
+ end
192
+
193
+ # Create statement object
194
+ statement = {
195
+ account: {
196
+ name: account['name'],
197
+ number: account['accountNumber'],
198
+ type: account['kind']
199
+ },
200
+ period: {
201
+ start_date: start_date,
202
+ end_date: end_date
203
+ },
204
+ balances: {
205
+ opening: opening_balance,
206
+ closing: closing_balance
207
+ },
208
+ transactions: formatted_transactions,
209
+ generated_at: Time.now.iso8601
210
+ }
211
+
212
+ # Output or save
213
+ json_output = JSON.pretty_generate(statement)
214
+
215
+ if save_path
216
+ File.write(save_path, json_output)
217
+ puts "Statement saved to #{save_path}"
218
+ else
219
+ puts json_output
220
+ end
221
+ end
222
+
223
+ def generate_csv_statement(account, transactions, opening_balance, closing_balance, start_date, end_date, save_path)
224
+ require 'csv'
225
+
226
+ # Prepare CSV data
227
+ csv_data = []
228
+
229
+ # Add header row
230
+ csv_data << ["Date", "Description", "Amount", "Balance", "Status"]
231
+
232
+ # Add opening balance row
233
+ csv_data << [start_date, "Opening Balance", "", opening_balance, ""]
234
+
235
+ # Add transactions
236
+ running_balance = opening_balance
237
+ transactions.each do |t|
238
+ date = t["postedAt"] ? Time.parse(t["postedAt"]).strftime("%Y-%m-%d") : Time.parse(t["createdAt"]).strftime("%Y-%m-%d")
239
+ description = t["bankDescription"] || t["externalMemo"] || "Unknown transaction"
240
+ amount = t["amount"]
241
+ running_balance += amount
242
+
243
+ csv_data << [date, description, amount, running_balance, t["status"]]
244
+ end
245
+
246
+ # Add closing balance row
247
+ csv_data << [end_date || Time.now.strftime("%Y-%m-%d"), "Closing Balance", "", closing_balance, ""]
248
+
249
+ # Output or save
250
+ csv_output = CSV.generate do |csv|
251
+ csv_data.each { |row| csv << row }
252
+ end
253
+
254
+ if save_path
255
+ File.write(save_path, csv_output)
256
+ puts "Statement saved to #{save_path}"
257
+ else
258
+ puts csv_output
259
+ end
260
+ end
261
+ end
262
+ end
263
+ end
264
+ end
265
+ end