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,586 @@
1
+ module MercuryBanking
2
+ module Reports
3
+ # Module for generating balance sheet reports
4
+ module BalanceSheet
5
+ # Generate a ledger balance sheet
6
+ def generate_ledger_balance_sheet(file_path, end_date = nil)
7
+ # Check if ledger command exists
8
+ unless command_exists?('ledger')
9
+ puts "Error: 'ledger' command not found. Please install ledger (https://www.ledger-cli.org/)."
10
+ return nil
11
+ end
12
+
13
+ # Verify the file exists
14
+ unless File.exist?(file_path)
15
+ puts "Error: Ledger file not found at #{file_path}"
16
+ return nil
17
+ end
18
+
19
+ # Verify and potentially fix the ledger file
20
+ verified_file_path = verify_ledger_file_syntax(file_path)
21
+ return nil unless verified_file_path
22
+
23
+ # Generate the balance sheet
24
+ generate_ledger_balance_report(verified_file_path, end_date)
25
+ end
26
+
27
+ # Verify the syntax of a ledger file and fix common issues if needed
28
+ def verify_ledger_file_syntax(file_path)
29
+ check_cmd = "ledger -f #{file_path} --verify"
30
+ check_output = `#{check_cmd}`
31
+
32
+ if $?.success?
33
+ return file_path
34
+ else
35
+ puts "Warning: Ledger file verification failed. The file may contain syntax errors."
36
+ puts "Error output: #{check_output}"
37
+
38
+ # Try to identify problematic lines
39
+ problematic_lines = `grep -n '^[0-9]' #{file_path} | head -10`
40
+ puts "First few transaction lines for inspection:"
41
+ puts problematic_lines
42
+
43
+ # Try to fix common issues
44
+ puts "Attempting to fix common issues in the ledger file..."
45
+ fix_ledger_file_syntax(file_path)
46
+ end
47
+ end
48
+
49
+ # Fix common syntax issues in a ledger file
50
+ def fix_ledger_file_syntax(file_path)
51
+ fixed_file_path = "#{file_path}.fixed"
52
+
53
+ # Create a fixed version of the file with safer formatting
54
+ File.open(fixed_file_path, 'w') do |fixed_file|
55
+ File.foreach(file_path) do |line|
56
+ if line.match?(/^\d{4}[\/\-\.]\d{2}[\/\-\.]\d{2}/)
57
+ # This is a transaction line, ensure description is safe
58
+ date, description = line.strip.split(' ', 2)
59
+ if description && (description.match?(/^\d/) ||
60
+ description.match?(/^[v#]/) ||
61
+ description.match?(/^\s*\d{4}/) ||
62
+ description.match?(/^\s*\d{2}\/\d{2}/))
63
+ fixed_file.puts "#{date} - #{description}"
64
+ else
65
+ fixed_file.puts line
66
+ end
67
+ else
68
+ fixed_file.puts line
69
+ end
70
+ end
71
+ end
72
+
73
+ puts "Created fixed ledger file at #{fixed_file_path}"
74
+ fixed_file_path
75
+ end
76
+
77
+ # Generate a balance sheet report using ledger
78
+ def generate_ledger_balance_report(file_path, end_date = nil)
79
+ # Construct the ledger command
80
+ cmd = "ledger -f #{file_path}"
81
+ cmd += " --end #{end_date}" if end_date
82
+ cmd += " balance --flat Assets Liabilities Equity"
83
+
84
+ # Execute the command
85
+ output = `#{cmd}`
86
+
87
+ # Check if the command was successful
88
+ if $?.success?
89
+ output
90
+ else
91
+ puts "Error executing ledger command: #{cmd}"
92
+ puts "Error output: #{output}"
93
+
94
+ # Try with a more permissive approach
95
+ try_permissive_ledger_command(file_path, end_date)
96
+ end
97
+ end
98
+
99
+ # Try a more permissive ledger command as a fallback
100
+ def try_permissive_ledger_command(file_path, end_date = nil)
101
+ puts "Trying with more permissive options..."
102
+ cmd = "ledger -f #{file_path} --permissive"
103
+ cmd += " --end #{end_date}" if end_date
104
+ cmd += " balance --flat Assets Liabilities Equity"
105
+
106
+ output = `#{cmd}`
107
+
108
+ if $?.success?
109
+ output
110
+ else
111
+ puts "Error executing permissive ledger command."
112
+ puts "Error output: #{output}"
113
+ nil
114
+ end
115
+ end
116
+
117
+ # Generate a beancount balance sheet
118
+ def generate_beancount_balance_sheet(file_path, end_date = nil)
119
+ # Check if bean-report command exists
120
+ unless command_exists?('bean-report')
121
+ puts "Error: 'bean-report' command not found. Please install beancount (https://beancount.github.io/)."
122
+ return nil
123
+ end
124
+
125
+ # Construct the beancount command
126
+ cmd = "bean-report #{file_path} balances"
127
+ cmd += " --end #{end_date}" if end_date
128
+
129
+ # Execute the command
130
+ output = `#{cmd}`
131
+
132
+ # Check if the command was successful
133
+ if $?.success?
134
+ output
135
+ else
136
+ puts "Error executing bean-report command: #{cmd}"
137
+ puts output
138
+ nil
139
+ end
140
+ end
141
+
142
+ # Parse balance sheet output to extract account balances
143
+ def parse_balance_sheet_output(output, format, debug = false)
144
+ balances = {}
145
+
146
+ if debug
147
+ puts "\n=== Balance Sheet Parsing Debug ==="
148
+ puts "Raw output to parse:"
149
+ puts output
150
+ end
151
+
152
+ case format
153
+ when 'ledger'
154
+ balances = parse_ledger_balance_output(output, debug)
155
+ when 'beancount'
156
+ balances = parse_beancount_balance_output(output, debug)
157
+ else
158
+ puts "Unsupported format: #{format}"
159
+ end
160
+
161
+ balances
162
+ end
163
+
164
+ def parse_ledger_balance_output(output, debug = false)
165
+ balances = {}
166
+
167
+ output.each_line do |line|
168
+ puts "Parsing line: #{line.inspect}" if debug
169
+ line = line.strip
170
+
171
+ # Skip empty lines and summary lines
172
+ if line.empty? || line.include?('----')
173
+ puts " Skipping line (empty or summary)" if debug
174
+ next
175
+ end
176
+
177
+ # Extract account name and balance using a more flexible regex
178
+ # The ledger output format is typically something like:
179
+ # $18.10 Assets:Mercury Checking ••1090
180
+ if match = line.match(/\$\s*([\d,.-]+)\s+(.+)/)
181
+ balance = match[1].gsub(',', '').to_f
182
+ account = match[2].strip
183
+
184
+ puts " Extracted: Account='#{account}', Balance=$#{balance}" if debug
185
+
186
+ # Add to balances hash
187
+ balances[account] = balance
188
+ puts " Added to balances hash" if debug
189
+ else
190
+ puts " No match for balance pattern" if debug
191
+ end
192
+ end
193
+
194
+ balances
195
+ end
196
+
197
+ def parse_beancount_balance_output(output, debug = false)
198
+ balances = {}
199
+
200
+ output.each_line do |line|
201
+ # Skip header lines and summary lines
202
+ next if line.strip.empty? || line.include?('---') || line.include?('Assets:') || line.include?('Liabilities:')
203
+
204
+ # Extract account name and balance
205
+ if line =~ /(\S.*?)\s+([\d,.-]+)\s+USD/
206
+ account = $1.strip
207
+ balance = $2.gsub(',', '').to_f
208
+
209
+ # Add to balances hash
210
+ balances[account] = balance
211
+ end
212
+ end
213
+
214
+ balances
215
+ end
216
+
217
+ # Generate ledger reports (P&L, balance sheet, etc.)
218
+ def generate_ledger_reports(file_path, report_type, category = nil, end_date = nil)
219
+ # Check if ledger is installed
220
+ unless command_exists?('ledger')
221
+ puts "Error: 'ledger' command not found. Please install Ledger CLI to generate reports."
222
+ return
223
+ end
224
+
225
+ # Build date filter
226
+ date_filter = end_date ? " --end #{end_date}" : ""
227
+
228
+ # Build category filter
229
+ category_filter = category ? " #{category}" : ""
230
+
231
+ # Prepare to capture output
232
+ report_output = ""
233
+ report_output << "=== Mercury Banking Ledger Reports ===\n\n"
234
+
235
+ puts "\n=== Mercury Banking Ledger Reports ===\n"
236
+
237
+ # Generate requested reports
238
+ if ['all', 'balance'].include?(report_type)
239
+ report_output << generate_ledger_balance_report_section(file_path, date_filter, category_filter)
240
+ end
241
+
242
+ if ['all', 'pl'].include?(report_type)
243
+ report_output << generate_ledger_pl_report_section(file_path, date_filter, category_filter)
244
+ end
245
+
246
+ if ['all', 'monthly'].include?(report_type)
247
+ report_output << generate_ledger_monthly_report_section(file_path, date_filter, category)
248
+ end
249
+
250
+ if report_type == 'register'
251
+ report_output << generate_ledger_register_report_section(file_path, date_filter, category)
252
+ end
253
+
254
+ return report_output
255
+ end
256
+
257
+ def generate_ledger_balance_report_section(file_path, date_filter, category_filter)
258
+ section_output = ""
259
+
260
+ balance_cmd = "ledger -f #{file_path}#{date_filter} balance --flat Assets Liabilities Equity#{category_filter}"
261
+ balance_output = `#{balance_cmd}`
262
+
263
+ puts "\n=== Balance Sheet ===\n"
264
+ puts balance_output
265
+
266
+ section_output << "=== Balance Sheet ===\n"
267
+ section_output << balance_output
268
+ section_output << "\n"
269
+
270
+ section_output
271
+ end
272
+
273
+ def generate_ledger_pl_report_section(file_path, date_filter, category_filter)
274
+ section_output = ""
275
+
276
+ pl_cmd = "ledger -f #{file_path}#{date_filter} balance --flat Income Expenses#{category_filter}"
277
+ pl_output = `#{pl_cmd}`
278
+
279
+ puts "\n=== Income Statement ===\n"
280
+ puts pl_output
281
+
282
+ section_output << "=== Income Statement ===\n"
283
+ section_output << pl_output
284
+ section_output << "\n"
285
+
286
+ section_output
287
+ end
288
+
289
+ def generate_ledger_monthly_report_section(file_path, date_filter, category)
290
+ section_output = ""
291
+
292
+ if category
293
+ monthly_cmd = "ledger -f #{file_path}#{date_filter} --monthly --collapse register #{category}"
294
+ monthly_output = `#{monthly_cmd}`
295
+
296
+ puts "\n=== Monthly #{category} ===\n"
297
+ puts monthly_output
298
+
299
+ section_output << "=== Monthly #{category} ===\n"
300
+ section_output << monthly_output
301
+ section_output << "\n"
302
+ else
303
+ # Generate monthly expenses report
304
+ section_output << generate_ledger_monthly_category_report(file_path, date_filter, "Expenses")
305
+
306
+ # Generate monthly income report
307
+ section_output << generate_ledger_monthly_category_report(file_path, date_filter, "Income")
308
+ end
309
+
310
+ section_output
311
+ end
312
+
313
+ def generate_ledger_monthly_category_report(file_path, date_filter, category)
314
+ section_output = ""
315
+
316
+ monthly_cmd = "ledger -f #{file_path}#{date_filter} --monthly --collapse register #{category}"
317
+ monthly_output = `#{monthly_cmd}`
318
+
319
+ puts "\n=== Monthly #{category} ===\n"
320
+ puts monthly_output
321
+
322
+ section_output << "=== Monthly #{category} ===\n"
323
+ section_output << monthly_output
324
+ section_output << "\n"
325
+
326
+ section_output
327
+ end
328
+
329
+ def generate_ledger_register_report_section(file_path, date_filter, category)
330
+ section_output = ""
331
+
332
+ register_filter = category || "Assets Liabilities Equity Income Expenses"
333
+ register_cmd = "ledger -f #{file_path}#{date_filter} register #{register_filter}"
334
+ register_output = `#{register_cmd}`
335
+
336
+ puts "\n=== Transaction Register ===\n"
337
+ puts register_output
338
+
339
+ section_output << "=== Transaction Register ===\n"
340
+ section_output << register_output
341
+ section_output << "\n"
342
+
343
+ section_output
344
+ end
345
+
346
+ # Generate beancount reports
347
+ def generate_beancount_reports(file_path, report_type, category = nil, end_date = nil)
348
+ # Check if bean-report is installed
349
+ unless command_exists?('bean-report')
350
+ puts "Error: 'bean-report' command not found. Please install Beancount to generate reports."
351
+ return
352
+ end
353
+
354
+ # Prepare to capture output
355
+ report_output = ""
356
+ report_output << "=== Mercury Banking Beancount Reports ===\n\n"
357
+
358
+ puts "\n=== Mercury Banking Beancount Reports ===\n"
359
+
360
+ # Build date filter (beancount uses different syntax)
361
+ date_filter = end_date ? " -e #{end_date}" : ""
362
+
363
+ # Generate requested reports
364
+ if ['all', 'balance'].include?(report_type)
365
+ report_output << generate_beancount_balance_report_section(file_path, date_filter)
366
+ end
367
+
368
+ if ['all', 'pl'].include?(report_type)
369
+ report_output << generate_beancount_pl_report_section(file_path, date_filter)
370
+ end
371
+
372
+ if ['all', 'monthly'].include?(report_type)
373
+ report_output << generate_beancount_monthly_report_section(file_path, date_filter)
374
+ end
375
+
376
+ if report_type == 'register'
377
+ report_output << generate_beancount_register_report_section(file_path, date_filter, category)
378
+ end
379
+
380
+ return report_output
381
+ end
382
+
383
+ def generate_beancount_balance_report_section(file_path, date_filter)
384
+ section_output = ""
385
+
386
+ balance_cmd = "bean-report #{file_path}#{date_filter} balsheet"
387
+ balance_output = `#{balance_cmd}`
388
+
389
+ puts "\n=== Balance Sheet ===\n"
390
+ puts balance_output
391
+
392
+ section_output << "=== Balance Sheet ===\n"
393
+ section_output << balance_output
394
+ section_output << "\n"
395
+
396
+ section_output
397
+ end
398
+
399
+ def generate_beancount_pl_report_section(file_path, date_filter)
400
+ section_output = ""
401
+
402
+ pl_cmd = "bean-report #{file_path}#{date_filter} income"
403
+ pl_output = `#{pl_cmd}`
404
+
405
+ puts "\n=== Income Statement ===\n"
406
+ puts pl_output
407
+
408
+ section_output << "=== Income Statement ===\n"
409
+ section_output << pl_output
410
+ section_output << "\n"
411
+
412
+ section_output
413
+ end
414
+
415
+ def generate_beancount_monthly_report_section(file_path, date_filter)
416
+ section_output = ""
417
+
418
+ monthly_cmd = "bean-report #{file_path}#{date_filter} monthly"
419
+ monthly_output = `#{monthly_cmd}`
420
+
421
+ puts "\n=== Monthly Activity ===\n"
422
+ puts monthly_output
423
+
424
+ section_output << "=== Monthly Activity ===\n"
425
+ section_output << monthly_output
426
+ section_output << "\n"
427
+
428
+ section_output
429
+ end
430
+
431
+ def generate_beancount_register_report_section(file_path, date_filter, category)
432
+ section_output = ""
433
+
434
+ register_filter = category ? " --account #{category}" : ""
435
+ register_cmd = "bean-report #{file_path}#{date_filter}#{register_filter} register"
436
+ register_output = `#{register_cmd}`
437
+
438
+ puts "\n=== Transaction Register ===\n"
439
+ puts register_output
440
+
441
+ section_output << "=== Transaction Register ===\n"
442
+ section_output << register_output
443
+ section_output << "\n"
444
+
445
+ section_output
446
+ end
447
+
448
+ def export_transactions_to_ledger(transactions, file_path)
449
+ # Create a temporary file for ledger export
450
+ File.open(file_path, 'w') do |f|
451
+ write_ledger_header(f)
452
+ write_ledger_account_declarations(f, transactions)
453
+ write_ledger_transactions(f, transactions)
454
+ end
455
+ end
456
+
457
+ def write_ledger_header(file)
458
+ file.puts "; Mercury Bank Transactions Export"
459
+ file.puts "; Generated on #{Time.now.strftime('%Y-%m-%d %H:%M:%S')}"
460
+ file.puts
461
+ end
462
+
463
+ def write_ledger_account_declarations(file, transactions)
464
+ # Get unique account names from transactions
465
+ account_names = transactions.map { |t| t["accountName"] || "Unknown Account" }.uniq
466
+
467
+ # Define accounts dynamically based on transaction data
468
+ account_names.each do |account_name|
469
+ file.puts "account Assets:#{account_name}"
470
+ end
471
+ file.puts "account Expenses:Unknown"
472
+ file.puts "account Income:Unknown"
473
+ file.puts
474
+ end
475
+
476
+ def write_ledger_transactions(file, transactions)
477
+ # Filter out failed transactions
478
+ valid_transactions = transactions.reject { |t| t["status"] == "failed" }
479
+
480
+ # Sort transactions by date
481
+ valid_transactions.sort_by! { |t| t["postedAt"] || t["createdAt"] }.each do |t|
482
+ write_ledger_transaction(file, t)
483
+ end
484
+ end
485
+
486
+ def write_ledger_transaction(file, transaction)
487
+ date = transaction["postedAt"] ?
488
+ Time.parse(transaction["postedAt"]).strftime("%Y/%m/%d") :
489
+ Time.parse(transaction["createdAt"]).strftime("%Y/%m/%d")
490
+ description = transaction["bankDescription"] || transaction["externalMemo"] || "Unknown transaction"
491
+ amount = transaction["amount"]
492
+ account_name = transaction["accountName"] || "Unknown Account"
493
+
494
+ file.puts "#{date} #{description}"
495
+ file.puts " ; Transaction ID: #{transaction["id"]}"
496
+ file.puts " ; Status: #{transaction["status"]}"
497
+ file.puts " ; Reconciled: No"
498
+
499
+ write_ledger_postings(file, amount, account_name)
500
+ file.puts
501
+ end
502
+
503
+ def write_ledger_postings(file, amount, account_name)
504
+ if amount > 0
505
+ file.puts " Income:Unknown $-#{format("%.2f", amount)}"
506
+ file.puts " Assets:#{account_name} $#{format("%.2f", amount)}"
507
+ else
508
+ file.puts " Expenses:Unknown $#{format("%.2f", amount.abs)}"
509
+ file.puts " Assets:#{account_name} $-#{format("%.2f", amount.abs)}"
510
+ end
511
+ end
512
+
513
+ def export_transactions_to_beancount(transactions, file_path)
514
+ # Create a temporary file for beancount export
515
+ File.open(file_path, 'w') do |f|
516
+ write_beancount_header(f)
517
+ write_beancount_account_declarations(f, transactions)
518
+ write_beancount_transactions(f, transactions)
519
+ end
520
+ end
521
+
522
+ def write_beancount_header(file)
523
+ file.puts "; Mercury Bank Transactions Export"
524
+ file.puts "; Generated on #{Time.now.strftime('%Y-%m-%d %H:%M:%S')}"
525
+ file.puts
526
+ file.puts "option \"title\" \"Mercury Bank Transactions\""
527
+ file.puts "option \"operating_currency\" \"USD\""
528
+ file.puts
529
+ end
530
+
531
+ def write_beancount_account_declarations(file, transactions)
532
+ # Get unique account names from transactions
533
+ account_names = transactions.map { |t| t["accountName"] || "Unknown Account" }.uniq
534
+
535
+ # Define accounts dynamically based on transaction data
536
+ account_names.each do |account_name|
537
+ # Sanitize account name for beancount format
538
+ safe_account_name = account_name.gsub(/[^a-zA-Z0-9]/, '-')
539
+ file.puts "1970-01-01 open Assets:#{safe_account_name}"
540
+ end
541
+ file.puts "1970-01-01 open Expenses:Unknown"
542
+ file.puts "1970-01-01 open Income:Unknown"
543
+ file.puts
544
+ end
545
+
546
+ def write_beancount_transactions(file, transactions)
547
+ # Filter out failed transactions
548
+ valid_transactions = transactions.reject { |t| t["status"] == "failed" }
549
+
550
+ # Sort transactions by date
551
+ valid_transactions.sort_by! { |t| t["postedAt"] || t["createdAt"] }.each do |t|
552
+ write_beancount_transaction(file, t)
553
+ end
554
+ end
555
+
556
+ def write_beancount_transaction(file, transaction)
557
+ date = transaction["postedAt"] ?
558
+ Time.parse(transaction["postedAt"]).strftime("%Y-%m-%d") :
559
+ Time.parse(transaction["createdAt"]).strftime("%Y-%m-%d")
560
+ description = transaction["bankDescription"] || transaction["externalMemo"] || "Unknown transaction"
561
+ amount = transaction["amount"]
562
+ account_name = transaction["accountName"] || "Unknown Account"
563
+ # Sanitize account name for beancount format
564
+ safe_account_name = account_name.gsub(/[^a-zA-Z0-9]/, '-')
565
+
566
+ file.puts "#{date} * \"#{description}\""
567
+ file.puts " ; Transaction ID: #{transaction["id"]}"
568
+ file.puts " ; Status: #{transaction["status"]}"
569
+ file.puts " ; Reconciled: No"
570
+
571
+ write_beancount_postings(file, amount, safe_account_name)
572
+ file.puts
573
+ end
574
+
575
+ def write_beancount_postings(file, amount, account_name)
576
+ if amount > 0
577
+ file.puts " Income:Unknown -#{format("%.2f", amount)} USD"
578
+ file.puts " Assets:#{account_name} #{format("%.2f", amount)} USD"
579
+ else
580
+ file.puts " Expenses:Unknown #{format("%.2f", amount.abs)} USD"
581
+ file.puts " Assets:#{account_name} -#{format("%.2f", amount.abs)} USD"
582
+ end
583
+ end
584
+ end
585
+ end
586
+ end