mercury_banking 0.5.38 → 0.7.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.
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module MercuryBanking
2
4
  module Reports
3
5
  # Module for reconciliation between ledger and Mercury balances
@@ -5,42 +7,42 @@ module MercuryBanking
5
7
  # Generate a reconciliation report to identify when balances diverged
6
8
  def generate_reconciliation_report(client, account_name, start_date, end_date = nil, format = 'ledger')
7
9
  puts "Generating reconciliation report for #{account_name} from #{start_date} to #{end_date || 'present'}..."
8
-
10
+
9
11
  # Find the account by name
10
12
  account = find_account_by_name(client, account_name)
11
13
  return unless account
12
-
14
+
13
15
  # Get current balance from Mercury
14
16
  current_mercury_balance = account['currentBalance']
15
-
17
+
16
18
  # Get all transactions for the account
17
19
  transactions = get_account_transactions(client, account['id'], start_date)
18
-
20
+
19
21
  # Filter by end date if specified
20
22
  if end_date
21
23
  end_date_obj = Date.parse(end_date)
22
24
  transactions = transactions.select do |t|
23
- transaction_date = t["postedAt"] ? Date.parse(t["postedAt"]) : Date.parse(t["createdAt"])
25
+ transaction_date = Date.parse(t["postedAt"] || t["createdAt"])
24
26
  transaction_date <= end_date_obj
25
27
  end
26
28
  end
27
-
29
+
28
30
  if transactions.empty?
29
31
  puts "No transactions found for reconciliation."
30
32
  return
31
33
  end
32
-
34
+
33
35
  # Sort transactions by date
34
36
  transactions = sort_transactions_by_date(transactions)
35
-
37
+
36
38
  # Group transactions by month for easier analysis
37
39
  monthly_transactions = group_transactions_by_month(transactions)
38
-
40
+
39
41
  # Create a temporary file for ledger export
40
42
  require 'tempfile'
41
43
  temp_file = Tempfile.new(['mercury_reconciliation', ".#{format}"])
42
44
  output_file = temp_file.path
43
-
45
+
44
46
  # Export all transactions to the ledger file
45
47
  case format
46
48
  when 'ledger'
@@ -52,48 +54,47 @@ module MercuryBanking
52
54
  temp_file.unlink
53
55
  return
54
56
  end
55
-
57
+
56
58
  # Analyze each month to find when divergence occurred
57
59
  puts "\n=== Monthly Reconciliation Analysis ==="
58
- puts "Period".ljust(15) + "Mercury Balance".ljust(20) + "Ledger Balance".ljust(20) + "Difference".ljust(15) + "Status"
60
+ puts "#{'Period'.ljust(15)}#{'Mercury Balance'.ljust(20)}#{'Ledger Balance'.ljust(20)}#{'Difference'.ljust(15)}Status"
59
61
  puts "-" * 80
60
-
61
- cumulative_ledger_balance = 0
62
+
63
+ # Initialize variables for tracking
62
64
  divergence_point = nil
63
65
  monthly_data = []
64
-
66
+
65
67
  monthly_transactions.each do |month, month_transactions|
66
68
  # Calculate the Mercury balance at the end of this month
67
69
  last_transaction = month_transactions.last
68
- last_date = last_transaction["postedAt"] ? Date.parse(last_transaction["postedAt"]) : Date.parse(last_transaction["createdAt"])
69
-
70
+ last_date = Date.parse(last_transaction["postedAt"] || last_transaction["createdAt"])
71
+
70
72
  # Get ledger balance for this month
71
73
  month_end_date = last_date.strftime("%Y-%m-%d")
72
74
  ledger_balance = get_ledger_balance_at_date(output_file, month_end_date, account_name, format)
73
-
75
+
74
76
  # Calculate Mercury balance at this point in time
75
77
  # This is an approximation based on current balance and subsequent transactions
76
- mercury_balance_at_month_end = calculate_mercury_balance_at_date(current_mercury_balance, transactions, last_date)
77
-
78
+ mercury_balance_at_month_end = calculate_mercury_balance_at_date(current_mercury_balance, transactions,
79
+ last_date)
80
+
78
81
  # Calculate difference
79
82
  difference = mercury_balance_at_month_end - ledger_balance
80
-
83
+
81
84
  # Determine status
82
85
  status = difference.abs < 0.01 ? "✓ Balanced" : "⚠️ Diverged"
83
-
86
+
84
87
  # Record the first divergence point
85
- if difference.abs >= 0.01 && divergence_point.nil?
86
- divergence_point = month
87
- end
88
-
88
+ divergence_point = month if difference.abs >= 0.01 && divergence_point.nil?
89
+
89
90
  # Format for display
90
91
  month_str = month
91
92
  mercury_balance_str = format("$%.2f", mercury_balance_at_month_end)
92
93
  ledger_balance_str = format("$%.2f", ledger_balance)
93
94
  difference_str = format("$%.2f", difference)
94
-
95
+
95
96
  puts month_str.ljust(15) + mercury_balance_str.ljust(20) + ledger_balance_str.ljust(20) + difference_str.ljust(15) + status
96
-
97
+
97
98
  monthly_data << {
98
99
  period: month,
99
100
  mercury_balance: mercury_balance_at_month_end,
@@ -102,78 +103,80 @@ module MercuryBanking
102
103
  status: status
103
104
  }
104
105
  end
105
-
106
+
106
107
  puts "-" * 80
107
-
108
+
108
109
  # If divergence was found, analyze the specific month in more detail
109
110
  if divergence_point
110
111
  puts "\n=== Detailed Analysis of Divergence Point: #{divergence_point} ==="
111
-
112
+
112
113
  # Get transactions for the divergence month
113
114
  divergence_month_transactions = monthly_transactions[divergence_point]
114
-
115
+
115
116
  # Check for pending or failed transactions
116
- pending_transactions = divergence_month_transactions.select { |t| t["status"] != "posted" }
117
-
117
+ pending_transactions = divergence_month_transactions.reject { |t| t["status"] == "posted" }
118
+
118
119
  if pending_transactions.any?
119
120
  puts "\nPotential issues found: #{pending_transactions.count} transactions with non-posted status"
120
121
  puts "\nTransactions with non-posted status:"
121
-
122
+
122
123
  pending_transactions.each do |t|
123
124
  date = t["postedAt"] ? Time.parse(t["postedAt"]).strftime("%Y-%m-%d") : Time.parse(t["createdAt"]).strftime("%Y-%m-%d")
124
125
  amount = format("$%.2f", t["amount"])
125
126
  description = t["bankDescription"] || t["externalMemo"] || "Unknown transaction"
126
127
  status = t["status"]
127
-
128
+
128
129
  puts "#{date} | #{amount} | #{status.upcase} | #{description}"
129
130
  end
130
131
  end
131
-
132
+
132
133
  # Check for transactions on the same day with opposite amounts (potential duplicates or corrections)
133
134
  potential_corrections = find_potential_corrections(divergence_month_transactions)
134
-
135
+
135
136
  if potential_corrections.any?
136
137
  puts "\nPotential corrections or duplicates found:"
137
-
138
+
138
139
  potential_corrections.each do |pair|
139
140
  t1, t2 = pair
140
141
  date1 = t1["postedAt"] ? Time.parse(t1["postedAt"]).strftime("%Y-%m-%d") : Time.parse(t1["createdAt"]).strftime("%Y-%m-%d")
141
142
  date2 = t2["postedAt"] ? Time.parse(t2["postedAt"]).strftime("%Y-%m-%d") : Time.parse(t2["createdAt"]).strftime("%Y-%m-%d")
142
-
143
- puts "#{date1} | #{format("$%.2f", t1["amount"])} | #{t1["bankDescription"] || t1["externalMemo"] || "Unknown"}"
144
- puts "#{date2} | #{format("$%.2f", t2["amount"])} | #{t2["bankDescription"] || t2["externalMemo"] || "Unknown"}"
143
+
144
+ puts "#{date1} | #{format('$%.2f',
145
+ t1['amount'])} | #{t1['bankDescription'] || t1['externalMemo'] || 'Unknown'}"
146
+ puts "#{date2} | #{format('$%.2f',
147
+ t2['amount'])} | #{t2['bankDescription'] || t2['externalMemo'] || 'Unknown'}"
145
148
  puts "-" * 50
146
149
  end
147
150
  end
148
151
  end
149
-
152
+
150
153
  # Clean up temporary file
151
154
  temp_file.unlink
152
-
153
- return monthly_data
155
+
156
+ monthly_data
154
157
  end
155
-
158
+
156
159
  private
157
-
160
+
158
161
  # Find account by name
159
162
  def find_account_by_name(client, account_name)
160
163
  accounts = client.accounts
161
164
  account = accounts.find { |a| a["name"] == account_name }
162
-
165
+
163
166
  unless account
164
167
  puts "Error: Account '#{account_name}' not found. Available accounts:"
165
168
  accounts.each { |a| puts "- #{a['name']}" }
166
169
  return nil
167
170
  end
168
-
171
+
169
172
  account
170
173
  end
171
-
174
+
172
175
  # Get transactions for a specific account
173
176
  def get_account_transactions(client, account_id, start_date)
174
177
  client.get_transactions(account_id, start_date)
175
178
  end
176
-
179
+
177
180
  # Sort transactions by date
178
181
  def sort_transactions_by_date(transactions)
179
182
  transactions.sort_by do |t|
@@ -181,15 +184,15 @@ module MercuryBanking
181
184
  Time.parse(date)
182
185
  end
183
186
  end
184
-
187
+
185
188
  # Group transactions by month
186
189
  def group_transactions_by_month(transactions)
187
190
  transactions.group_by do |t|
188
- date = t["postedAt"] ? Time.parse(t["postedAt"]) : Time.parse(t["createdAt"])
191
+ date = Time.parse(t["postedAt"] || t["createdAt"])
189
192
  date.strftime("%Y-%m")
190
193
  end
191
194
  end
192
-
195
+
193
196
  # Get ledger balance at a specific date
194
197
  def get_ledger_balance_at_date(file_path, date, account_name, format)
195
198
  case format
@@ -199,48 +202,28 @@ module MercuryBanking
199
202
  accounts_output = `#{accounts_cmd}`
200
203
  puts "\n=== Available Accounts in Ledger File ==="
201
204
  puts accounts_output
202
-
205
+
203
206
  # Find the closest matching account
204
207
  ledger_accounts = accounts_output.split("\n")
205
-
206
- # Try to find an exact match first
207
- exact_match = ledger_accounts.find { |a| a == "Assets:#{account_name}" }
208
- if exact_match
209
- puts "Found exact account match: #{exact_match}"
210
- account_to_use = exact_match
211
- else
212
- # Try to find a partial match
213
- partial_matches = ledger_accounts.select { |a| a.include?(account_name.split(' ').first) }
214
- if partial_matches.any?
215
- puts "Found partial account matches: #{partial_matches.join(', ')}"
216
- account_to_use = partial_matches.first
217
- else
218
- # If no match found, just use the first Assets account
219
- assets_accounts = ledger_accounts.select { |a| a.start_with?('Assets:') }
220
- if assets_accounts.any?
221
- puts "No match found, using first Assets account: #{assets_accounts.first}"
222
- account_to_use = assets_accounts.first
223
- else
224
- puts "No Assets accounts found in ledger file"
225
- return 0.0
226
- end
227
- end
228
- end
229
-
208
+
209
+ # Find the appropriate account to use
210
+ account_to_use = find_matching_account(ledger_accounts, account_name)
211
+ return 0.0 unless account_to_use
212
+
230
213
  # Now use the matched account for the balance command
231
214
  cmd = "ledger -f #{file_path} --end #{date} balance --flat '#{account_to_use}'"
232
215
  output = `#{cmd}`
233
-
216
+
234
217
  # Debug information
235
218
  puts "\n=== Ledger Debug Information ==="
236
219
  puts "Command: #{cmd}"
237
220
  puts "Raw output: #{output}"
238
-
221
+
239
222
  # Extract balance from output using a more flexible regex
240
223
  # The ledger output format is typically something like:
241
224
  # $2950.00 Assets:Mercury Checking
242
225
  if output =~ /\s+\$\s*([\d,.-]+)\s+/
243
- balance = $1.gsub(',', '').to_f
226
+ balance = ::Regexp.last_match(1).gsub(',', '').to_f
244
227
  puts "Extracted balance: $#{balance}"
245
228
  balance
246
229
  else
@@ -250,10 +233,10 @@ module MercuryBanking
250
233
  when 'beancount'
251
234
  cmd = "bean-report #{file_path} -e #{date} balances 'Assets:#{account_name.gsub(/[^a-zA-Z0-9]/, '-')}'"
252
235
  output = `#{cmd}`
253
-
236
+
254
237
  # Extract balance from output
255
238
  if output =~ /([\d,.-]+)\s+USD/
256
- $1.gsub(',', '').to_f
239
+ ::Regexp.last_match(1).gsub(',', '').to_f
257
240
  else
258
241
  0.0
259
242
  end
@@ -261,47 +244,78 @@ module MercuryBanking
261
244
  0.0
262
245
  end
263
246
  end
264
-
247
+
265
248
  # Calculate Mercury balance at a specific date
266
249
  def calculate_mercury_balance_at_date(current_balance, transactions, target_date)
267
250
  # Start with current balance
268
251
  balance_at_date = current_balance
269
-
252
+
270
253
  # Subtract all transactions that occurred after the target date
271
254
  transactions.each do |t|
272
- transaction_date = t["postedAt"] ? Date.parse(t["postedAt"]) : Date.parse(t["createdAt"])
273
-
255
+ transaction_date = Date.parse(t["postedAt"] || t["createdAt"])
256
+
274
257
  if transaction_date > target_date
275
258
  # Reverse the effect of this transaction
276
259
  balance_at_date -= t["amount"]
277
260
  end
278
261
  end
279
-
262
+
280
263
  balance_at_date
281
264
  end
282
-
265
+
283
266
  # Find potential corrections or duplicates
284
267
  def find_potential_corrections(transactions)
285
268
  potential_pairs = []
286
-
269
+
287
270
  # Group transactions by date
288
271
  by_date = transactions.group_by do |t|
289
- date = t["postedAt"] ? Time.parse(t["postedAt"]) : Time.parse(t["createdAt"])
272
+ date = Time.parse(t["postedAt"] || t["createdAt"])
290
273
  date.strftime("%Y-%m-%d")
291
274
  end
292
-
275
+
293
276
  # Look for transactions on the same day with opposite amounts
294
- by_date.each do |date, day_transactions|
277
+ by_date.each do |_date, day_transactions|
295
278
  day_transactions.combination(2).each do |t1, t2|
296
279
  # Check if amounts are opposite (with small tolerance for rounding)
297
- if (t1["amount"] + t2["amount"]).abs < 0.01
298
- potential_pairs << [t1, t2]
299
- end
280
+ potential_pairs << [t1, t2] if (t1["amount"] + t2["amount"]).abs < 0.01
300
281
  end
301
282
  end
302
-
283
+
303
284
  potential_pairs
304
285
  end
286
+
287
+ # Find a matching account in the ledger file
288
+ def find_matching_account(ledger_accounts, account_name)
289
+ # Try exact match first
290
+ exact_match = ledger_accounts.find { |a| a == "Assets:#{account_name}" }
291
+ if exact_match
292
+ puts "Found exact account match: #{exact_match}"
293
+ return exact_match
294
+ end
295
+
296
+ # Try partial match
297
+ partial_matches = ledger_accounts.select { |a| a.include?(account_name.split.first) }
298
+ if partial_matches.any?
299
+ puts "Found partial account matches: #{partial_matches.join(', ')}"
300
+ return partial_matches.first
301
+ end
302
+
303
+ # Fall back to any Assets account
304
+ find_fallback_account(ledger_accounts)
305
+ end
306
+
307
+ # Find a fallback account when no direct match is found
308
+ def find_fallback_account(ledger_accounts)
309
+ # Try to use the first Assets account
310
+ assets_accounts = ledger_accounts.select { |a| a.start_with?('Assets:') }
311
+ if assets_accounts.any?
312
+ puts "No match found, using first Assets account: #{assets_accounts.first}"
313
+ assets_accounts.first
314
+ else
315
+ puts "No Assets accounts found in ledger file"
316
+ nil
317
+ end
318
+ end
305
319
  end
306
320
  end
307
- end
321
+ end
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module MercuryBanking
2
4
  module Utils
3
5
  # Utility methods for command operations
@@ -15,4 +17,4 @@ module MercuryBanking
15
17
  end
16
18
  end
17
19
  end
18
- end
20
+ end
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module MercuryBanking
2
- VERSION = "0.5.38"
4
+ VERSION = "0.7.0"
3
5
  end
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require "mercury_banking/version"
2
4
  require 'uri'
3
5
  require 'net/http'
@@ -9,10 +11,7 @@ require 'csv'
9
11
  require 'mercury_banking/api'
10
12
  require 'mercury_banking/recipient'
11
13
  require 'mercury_banking/multi'
12
- require 'mercury_banking/reconciliation'
13
14
  require 'mercury_banking/utils/command_utils'
14
- require 'mercury_banking/reports/balance_sheet'
15
- require 'mercury_banking/reports/reconciliation'
16
15
 
17
16
  module MercuryBanking
18
17
  class Error < StandardError; end
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require_relative 'lib/mercury_banking/version'
2
4
 
3
5
  Gem::Specification.new do |spec|
@@ -6,9 +8,9 @@ Gem::Specification.new do |spec|
6
8
  spec.authors = ["Jonathan Siegel", "Yusuke Ishida"]
7
9
  spec.email = ["jonathan@siegel.io", "yusuke@xenon.io"]
8
10
 
9
- spec.summary = %q{Client library for talking to Mercury API.}
10
- spec.description = %q{Using this gem, you can access all of your accounts and their transaction histories or make payments to other accounts.
11
- }
11
+ spec.summary = 'Client library for talking to Mercury API.'
12
+ spec.description = 'Using this gem, you can access all of your accounts and their transaction histories or make payments to other accounts.
13
+ '
12
14
  spec.homepage = "https://github.com/XenonIO/mercury-banking"
13
15
  spec.license = "MIT"
14
16
  spec.required_ruby_version = Gem::Requirement.new(">= 3.0.0")
@@ -21,27 +23,28 @@ Gem::Specification.new do |spec|
21
23
 
22
24
  # Specify which files should be added to the gem when it is released.
23
25
  # The `git ls-files -z` loads the files in the RubyGem that have been added into git.
24
- spec.files = Dir.chdir(File.expand_path('..', __FILE__)) do
26
+ spec.files = Dir.chdir(File.expand_path(__dir__)) do
25
27
  `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) }
26
28
  end
27
29
  spec.bindir = "bin"
28
30
  spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) }
29
31
  spec.require_paths = ["lib"]
30
-
32
+
31
33
  # Development dependencies
32
- spec.add_development_dependency "rake", "~> 13.0"
33
34
  spec.add_development_dependency 'pry', '>= 0'
35
+ spec.add_development_dependency "rake", "~> 13.0"
34
36
  spec.add_development_dependency "rspec", "~> 3.0"
35
37
  spec.add_development_dependency 'webmock', '~> 3.18'
36
-
38
+
37
39
  # Runtime dependencies
38
- spec.add_dependency 'dotenv', '~> 2.8'
39
- spec.add_dependency 'thor', '~> 1.2.0'
40
- spec.add_dependency 'terminal-table', '~> 1.8.0'
41
40
  spec.add_dependency 'activesupport', '~> 7.0.0'
42
- spec.add_dependency 'symmetric-encryption', '~> 4.6.0'
41
+ spec.add_dependency 'dotenv', '~> 2.8'
42
+ spec.add_dependency 'fiddle', '~> 1.1'
43
43
  spec.add_dependency 'json', '~> 2.6'
44
44
  spec.add_dependency 'lockbox', '~> 1.1'
45
45
  spec.add_dependency 'logger', '~> 1.5'
46
- spec.add_dependency 'fiddle', '~> 1.1'
46
+ spec.add_dependency 'symmetric-encryption', '~> 4.6.0'
47
+ spec.add_dependency 'terminal-table', '~> 1.8.0'
48
+ spec.add_dependency 'thor', '~> 1.2.0'
49
+ spec.metadata['rubygems_mfa_required'] = 'true'
47
50
  end