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,307 @@
1
+ module MercuryBanking
2
+ module Reports
3
+ # Module for reconciliation between ledger and Mercury balances
4
+ module Reconciliation
5
+ # Generate a reconciliation report to identify when balances diverged
6
+ def generate_reconciliation_report(client, account_name, start_date, end_date = nil, format = 'ledger')
7
+ puts "Generating reconciliation report for #{account_name} from #{start_date} to #{end_date || 'present'}..."
8
+
9
+ # Find the account by name
10
+ account = find_account_by_name(client, account_name)
11
+ return unless account
12
+
13
+ # Get current balance from Mercury
14
+ current_mercury_balance = account['currentBalance']
15
+
16
+ # Get all transactions for the account
17
+ transactions = get_account_transactions(client, account['id'], start_date)
18
+
19
+ # Filter by end date if specified
20
+ if end_date
21
+ end_date_obj = Date.parse(end_date)
22
+ transactions = transactions.select do |t|
23
+ transaction_date = t["postedAt"] ? Date.parse(t["postedAt"]) : Date.parse(t["createdAt"])
24
+ transaction_date <= end_date_obj
25
+ end
26
+ end
27
+
28
+ if transactions.empty?
29
+ puts "No transactions found for reconciliation."
30
+ return
31
+ end
32
+
33
+ # Sort transactions by date
34
+ transactions = sort_transactions_by_date(transactions)
35
+
36
+ # Group transactions by month for easier analysis
37
+ monthly_transactions = group_transactions_by_month(transactions)
38
+
39
+ # Create a temporary file for ledger export
40
+ require 'tempfile'
41
+ temp_file = Tempfile.new(['mercury_reconciliation', ".#{format}"])
42
+ output_file = temp_file.path
43
+
44
+ # Export all transactions to the ledger file
45
+ case format
46
+ when 'ledger'
47
+ export_to_ledger(transactions, output_file, [], false)
48
+ when 'beancount'
49
+ export_to_beancount(transactions, output_file, [], false)
50
+ else
51
+ puts "Unsupported format: #{format}. Please use 'ledger' or 'beancount'."
52
+ temp_file.unlink
53
+ return
54
+ end
55
+
56
+ # Analyze each month to find when divergence occurred
57
+ puts "\n=== Monthly Reconciliation Analysis ==="
58
+ puts "Period".ljust(15) + "Mercury Balance".ljust(20) + "Ledger Balance".ljust(20) + "Difference".ljust(15) + "Status"
59
+ puts "-" * 80
60
+
61
+ cumulative_ledger_balance = 0
62
+ divergence_point = nil
63
+ monthly_data = []
64
+
65
+ monthly_transactions.each do |month, month_transactions|
66
+ # Calculate the Mercury balance at the end of this month
67
+ last_transaction = month_transactions.last
68
+ last_date = last_transaction["postedAt"] ? Date.parse(last_transaction["postedAt"]) : Date.parse(last_transaction["createdAt"])
69
+
70
+ # Get ledger balance for this month
71
+ month_end_date = last_date.strftime("%Y-%m-%d")
72
+ ledger_balance = get_ledger_balance_at_date(output_file, month_end_date, account_name, format)
73
+
74
+ # Calculate Mercury balance at this point in time
75
+ # 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
+ # Calculate difference
79
+ difference = mercury_balance_at_month_end - ledger_balance
80
+
81
+ # Determine status
82
+ status = difference.abs < 0.01 ? "✓ Balanced" : "⚠️ Diverged"
83
+
84
+ # Record the first divergence point
85
+ if difference.abs >= 0.01 && divergence_point.nil?
86
+ divergence_point = month
87
+ end
88
+
89
+ # Format for display
90
+ month_str = month
91
+ mercury_balance_str = format("$%.2f", mercury_balance_at_month_end)
92
+ ledger_balance_str = format("$%.2f", ledger_balance)
93
+ difference_str = format("$%.2f", difference)
94
+
95
+ puts month_str.ljust(15) + mercury_balance_str.ljust(20) + ledger_balance_str.ljust(20) + difference_str.ljust(15) + status
96
+
97
+ monthly_data << {
98
+ period: month,
99
+ mercury_balance: mercury_balance_at_month_end,
100
+ ledger_balance: ledger_balance,
101
+ difference: difference,
102
+ status: status
103
+ }
104
+ end
105
+
106
+ puts "-" * 80
107
+
108
+ # If divergence was found, analyze the specific month in more detail
109
+ if divergence_point
110
+ puts "\n=== Detailed Analysis of Divergence Point: #{divergence_point} ==="
111
+
112
+ # Get transactions for the divergence month
113
+ divergence_month_transactions = monthly_transactions[divergence_point]
114
+
115
+ # Check for pending or failed transactions
116
+ pending_transactions = divergence_month_transactions.select { |t| t["status"] != "posted" }
117
+
118
+ if pending_transactions.any?
119
+ puts "\nPotential issues found: #{pending_transactions.count} transactions with non-posted status"
120
+ puts "\nTransactions with non-posted status:"
121
+
122
+ pending_transactions.each do |t|
123
+ date = t["postedAt"] ? Time.parse(t["postedAt"]).strftime("%Y-%m-%d") : Time.parse(t["createdAt"]).strftime("%Y-%m-%d")
124
+ amount = format("$%.2f", t["amount"])
125
+ description = t["bankDescription"] || t["externalMemo"] || "Unknown transaction"
126
+ status = t["status"]
127
+
128
+ puts "#{date} | #{amount} | #{status.upcase} | #{description}"
129
+ end
130
+ end
131
+
132
+ # Check for transactions on the same day with opposite amounts (potential duplicates or corrections)
133
+ potential_corrections = find_potential_corrections(divergence_month_transactions)
134
+
135
+ if potential_corrections.any?
136
+ puts "\nPotential corrections or duplicates found:"
137
+
138
+ potential_corrections.each do |pair|
139
+ t1, t2 = pair
140
+ date1 = t1["postedAt"] ? Time.parse(t1["postedAt"]).strftime("%Y-%m-%d") : Time.parse(t1["createdAt"]).strftime("%Y-%m-%d")
141
+ 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"}"
145
+ puts "-" * 50
146
+ end
147
+ end
148
+ end
149
+
150
+ # Clean up temporary file
151
+ temp_file.unlink
152
+
153
+ return monthly_data
154
+ end
155
+
156
+ private
157
+
158
+ # Find account by name
159
+ def find_account_by_name(client, account_name)
160
+ accounts = client.accounts
161
+ account = accounts.find { |a| a["name"] == account_name }
162
+
163
+ unless account
164
+ puts "Error: Account '#{account_name}' not found. Available accounts:"
165
+ accounts.each { |a| puts "- #{a['name']}" }
166
+ return nil
167
+ end
168
+
169
+ account
170
+ end
171
+
172
+ # Get transactions for a specific account
173
+ def get_account_transactions(client, account_id, start_date)
174
+ client.get_transactions(account_id, start_date)
175
+ end
176
+
177
+ # Sort transactions by date
178
+ def sort_transactions_by_date(transactions)
179
+ transactions.sort_by do |t|
180
+ date = t["postedAt"] || t["createdAt"]
181
+ Time.parse(date)
182
+ end
183
+ end
184
+
185
+ # Group transactions by month
186
+ def group_transactions_by_month(transactions)
187
+ transactions.group_by do |t|
188
+ date = t["postedAt"] ? Time.parse(t["postedAt"]) : Time.parse(t["createdAt"])
189
+ date.strftime("%Y-%m")
190
+ end
191
+ end
192
+
193
+ # Get ledger balance at a specific date
194
+ def get_ledger_balance_at_date(file_path, date, account_name, format)
195
+ case format
196
+ when 'ledger'
197
+ # First, check what accounts actually exist in the ledger file
198
+ accounts_cmd = "ledger -f #{file_path} accounts"
199
+ accounts_output = `#{accounts_cmd}`
200
+ puts "\n=== Available Accounts in Ledger File ==="
201
+ puts accounts_output
202
+
203
+ # Find the closest matching account
204
+ 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
+
230
+ # Now use the matched account for the balance command
231
+ cmd = "ledger -f #{file_path} --end #{date} balance --flat '#{account_to_use}'"
232
+ output = `#{cmd}`
233
+
234
+ # Debug information
235
+ puts "\n=== Ledger Debug Information ==="
236
+ puts "Command: #{cmd}"
237
+ puts "Raw output: #{output}"
238
+
239
+ # Extract balance from output using a more flexible regex
240
+ # The ledger output format is typically something like:
241
+ # $2950.00 Assets:Mercury Checking
242
+ if output =~ /\s+\$\s*([\d,.-]+)\s+/
243
+ balance = $1.gsub(',', '').to_f
244
+ puts "Extracted balance: $#{balance}"
245
+ balance
246
+ else
247
+ puts "No balance found in output, returning 0.0"
248
+ 0.0
249
+ end
250
+ when 'beancount'
251
+ cmd = "bean-report #{file_path} -e #{date} balances 'Assets:#{account_name.gsub(/[^a-zA-Z0-9]/, '-')}'"
252
+ output = `#{cmd}`
253
+
254
+ # Extract balance from output
255
+ if output =~ /([\d,.-]+)\s+USD/
256
+ $1.gsub(',', '').to_f
257
+ else
258
+ 0.0
259
+ end
260
+ else
261
+ 0.0
262
+ end
263
+ end
264
+
265
+ # Calculate Mercury balance at a specific date
266
+ def calculate_mercury_balance_at_date(current_balance, transactions, target_date)
267
+ # Start with current balance
268
+ balance_at_date = current_balance
269
+
270
+ # Subtract all transactions that occurred after the target date
271
+ transactions.each do |t|
272
+ transaction_date = t["postedAt"] ? Date.parse(t["postedAt"]) : Date.parse(t["createdAt"])
273
+
274
+ if transaction_date > target_date
275
+ # Reverse the effect of this transaction
276
+ balance_at_date -= t["amount"]
277
+ end
278
+ end
279
+
280
+ balance_at_date
281
+ end
282
+
283
+ # Find potential corrections or duplicates
284
+ def find_potential_corrections(transactions)
285
+ potential_pairs = []
286
+
287
+ # Group transactions by date
288
+ by_date = transactions.group_by do |t|
289
+ date = t["postedAt"] ? Time.parse(t["postedAt"]) : Time.parse(t["createdAt"])
290
+ date.strftime("%Y-%m-%d")
291
+ end
292
+
293
+ # Look for transactions on the same day with opposite amounts
294
+ by_date.each do |date, day_transactions|
295
+ day_transactions.combination(2).each do |t1, t2|
296
+ # 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
300
+ end
301
+ end
302
+
303
+ potential_pairs
304
+ end
305
+ end
306
+ end
307
+ end
@@ -0,0 +1,18 @@
1
+ module MercuryBanking
2
+ module Utils
3
+ # Utility methods for command operations
4
+ module CommandUtils
5
+ # Check if a command exists in the system PATH
6
+ def command_exists?(command)
7
+ exts = ENV['PATHEXT'] ? ENV['PATHEXT'].split(';') : ['']
8
+ ENV['PATH'].split(File::PATH_SEPARATOR).each do |path|
9
+ exts.each do |ext|
10
+ exe = File.join(path, "#{command}#{ext}")
11
+ return true if File.executable?(exe) && !File.directory?(exe)
12
+ end
13
+ end
14
+ false
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,3 @@
1
+ module MercuryBanking
2
+ VERSION = "0.5.34"
3
+ end
@@ -0,0 +1,19 @@
1
+ require "mercury_banking/version"
2
+ require 'uri'
3
+ require 'net/http'
4
+ require 'json'
5
+ require 'terminal-table'
6
+ require 'csv'
7
+
8
+ # Load all modules
9
+ require 'mercury_banking/api'
10
+ require 'mercury_banking/recipient'
11
+ require 'mercury_banking/multi'
12
+ require 'mercury_banking/reconciliation'
13
+ require 'mercury_banking/utils/command_utils'
14
+ require 'mercury_banking/reports/balance_sheet'
15
+ require 'mercury_banking/reports/reconciliation'
16
+
17
+ module MercuryBanking
18
+ class Error < StandardError; end
19
+ end
@@ -0,0 +1,37 @@
1
+ require_relative 'lib/mercury_banking/version'
2
+
3
+ Gem::Specification.new do |spec|
4
+ spec.name = "mercury_banking"
5
+ spec.version = MercuryBanking::VERSION
6
+ spec.authors = ["Jonathan Siegel", "Yusuke Ishida"]
7
+ spec.email = ["jonathan@siegel.io", "yusuke@xenon.io"]
8
+
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
+ }
12
+ spec.homepage = "https://github.com/XenonIO/mercury-banking"
13
+ spec.license = "MIT"
14
+ spec.required_ruby_version = Gem::Requirement.new(">= 3.0.0")
15
+
16
+ spec.metadata["allowed_push_host"] = "https://rubygems.org"
17
+
18
+ spec.metadata["homepage_uri"] = spec.homepage
19
+ spec.metadata["source_code_uri"] = "https://github.com/XenonIO/mercury-banking"
20
+ spec.metadata["changelog_uri"] = "https://github.com/XenonIO/mercury-banking"
21
+
22
+ # Specify which files should be added to the gem when it is released.
23
+ # 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
25
+ `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) }
26
+ end
27
+ spec.bindir = "bin"
28
+ spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) }
29
+ spec.require_paths = ["lib"]
30
+ spec.add_development_dependency "rake", "~> 13.0"
31
+ spec.add_development_dependency 'pry', '>= 0'
32
+ spec.add_development_dependency "rspec", "~> 3.0"
33
+ spec.add_dependency 'thor', '~> 1.2.0'
34
+ spec.add_dependency 'terminal-table', '~> 1.8.0'
35
+ spec.add_dependency 'activesupport', '~> 7.0.0'
36
+ spec.add_dependency 'symmetric-encryption', '~> 4.6.0'
37
+ end
metadata ADDED
@@ -0,0 +1,183 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: mercury_banking
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.5.34
5
+ platform: ruby
6
+ authors:
7
+ - Jonathan Siegel
8
+ - Yusuke Ishida
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2025-03-10 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: rake
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '13.0'
20
+ type: :development
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '13.0'
27
+ - !ruby/object:Gem::Dependency
28
+ name: pry
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - ">="
32
+ - !ruby/object:Gem::Version
33
+ version: '0'
34
+ type: :development
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - ">="
39
+ - !ruby/object:Gem::Version
40
+ version: '0'
41
+ - !ruby/object:Gem::Dependency
42
+ name: rspec
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '3.0'
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '3.0'
55
+ - !ruby/object:Gem::Dependency
56
+ name: thor
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - "~>"
60
+ - !ruby/object:Gem::Version
61
+ version: 1.2.0
62
+ type: :runtime
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - "~>"
67
+ - !ruby/object:Gem::Version
68
+ version: 1.2.0
69
+ - !ruby/object:Gem::Dependency
70
+ name: terminal-table
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - "~>"
74
+ - !ruby/object:Gem::Version
75
+ version: 1.8.0
76
+ type: :runtime
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - "~>"
81
+ - !ruby/object:Gem::Version
82
+ version: 1.8.0
83
+ - !ruby/object:Gem::Dependency
84
+ name: activesupport
85
+ requirement: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - "~>"
88
+ - !ruby/object:Gem::Version
89
+ version: 7.0.0
90
+ type: :runtime
91
+ prerelease: false
92
+ version_requirements: !ruby/object:Gem::Requirement
93
+ requirements:
94
+ - - "~>"
95
+ - !ruby/object:Gem::Version
96
+ version: 7.0.0
97
+ - !ruby/object:Gem::Dependency
98
+ name: symmetric-encryption
99
+ requirement: !ruby/object:Gem::Requirement
100
+ requirements:
101
+ - - "~>"
102
+ - !ruby/object:Gem::Version
103
+ version: 4.6.0
104
+ type: :runtime
105
+ prerelease: false
106
+ version_requirements: !ruby/object:Gem::Requirement
107
+ requirements:
108
+ - - "~>"
109
+ - !ruby/object:Gem::Version
110
+ version: 4.6.0
111
+ description: "Using this gem, you can access all of your accounts and their transaction
112
+ histories or make payments to other accounts.\n "
113
+ email:
114
+ - jonathan@siegel.io
115
+ - yusuke@xenon.io
116
+ executables:
117
+ - console
118
+ - mercury
119
+ - setup
120
+ extensions: []
121
+ extra_rdoc_files: []
122
+ files:
123
+ - ".env_test"
124
+ - ".gitignore"
125
+ - ".rspec"
126
+ - ".rubocop.yml"
127
+ - ".travis.yml"
128
+ - CHANGELOG.md
129
+ - CODE_OF_CONDUCT.md
130
+ - Gemfile
131
+ - Gemfile.lock
132
+ - LICENSE
133
+ - LINTING_REPORT.md
134
+ - README.md
135
+ - Rakefile
136
+ - bin/console
137
+ - bin/mercury
138
+ - bin/setup
139
+ - lib/mercury_banking.rb
140
+ - lib/mercury_banking/api.rb
141
+ - lib/mercury_banking/cli.rb
142
+ - lib/mercury_banking/cli/accounts.rb
143
+ - lib/mercury_banking/cli/base.rb
144
+ - lib/mercury_banking/cli/financials.rb
145
+ - lib/mercury_banking/cli/reconciliation.rb
146
+ - lib/mercury_banking/cli/reports.rb
147
+ - lib/mercury_banking/cli/transactions.rb
148
+ - lib/mercury_banking/formatters/export_formatter.rb
149
+ - lib/mercury_banking/formatters/table_formatter.rb
150
+ - lib/mercury_banking/multi.rb
151
+ - lib/mercury_banking/recipient.rb
152
+ - lib/mercury_banking/reconciliation.rb
153
+ - lib/mercury_banking/reports/balance_sheet.rb
154
+ - lib/mercury_banking/reports/reconciliation.rb
155
+ - lib/mercury_banking/utils/command_utils.rb
156
+ - lib/mercury_banking/version.rb
157
+ - mercury_banking.gemspec
158
+ homepage: https://github.com/XenonIO/mercury-banking
159
+ licenses:
160
+ - MIT
161
+ metadata:
162
+ allowed_push_host: https://rubygems.org
163
+ homepage_uri: https://github.com/XenonIO/mercury-banking
164
+ source_code_uri: https://github.com/XenonIO/mercury-banking
165
+ changelog_uri: https://github.com/XenonIO/mercury-banking
166
+ rdoc_options: []
167
+ require_paths:
168
+ - lib
169
+ required_ruby_version: !ruby/object:Gem::Requirement
170
+ requirements:
171
+ - - ">="
172
+ - !ruby/object:Gem::Version
173
+ version: 3.0.0
174
+ required_rubygems_version: !ruby/object:Gem::Requirement
175
+ requirements:
176
+ - - ">="
177
+ - !ruby/object:Gem::Version
178
+ version: '0'
179
+ requirements: []
180
+ rubygems_version: 3.6.5
181
+ specification_version: 4
182
+ summary: Client library for talking to Mercury API.
183
+ test_files: []