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.
- checksums.yaml +4 -4
- data/.rubocop.yml +46 -2
- data/CHANGELOG.md +16 -0
- data/Gemfile +13 -12
- data/Gemfile.lock +1 -1
- data/Rakefile +3 -1
- data/bin/console +1 -0
- data/bin/mercury +2 -1
- data/lib/mercury_banking/api.rb +26 -23
- data/lib/mercury_banking/cli/accounts.rb +24 -24
- data/lib/mercury_banking/cli/base.rb +48 -26
- data/lib/mercury_banking/cli/financials.rb +177 -252
- data/lib/mercury_banking/cli/reconciliation.rb +284 -371
- data/lib/mercury_banking/cli/reports.rb +82 -74
- data/lib/mercury_banking/cli/transactions.rb +60 -62
- data/lib/mercury_banking/cli.rb +56 -51
- data/lib/mercury_banking/formatters/export_formatter.rb +99 -97
- data/lib/mercury_banking/formatters/table_formatter.rb +32 -30
- data/lib/mercury_banking/multi.rb +43 -37
- data/lib/mercury_banking/recipient.rb +17 -9
- data/lib/mercury_banking/reconciliation.rb +57 -58
- data/lib/mercury_banking/reports/balance_sheet.rb +210 -218
- data/lib/mercury_banking/reports/reconciliation.rb +114 -100
- data/lib/mercury_banking/utils/command_utils.rb +3 -1
- data/lib/mercury_banking/version.rb +3 -1
- data/lib/mercury_banking.rb +2 -3
- data/mercury_banking.gemspec +15 -12
- metadata +40 -39
@@ -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 =
|
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
|
60
|
+
puts "#{'Period'.ljust(15)}#{'Mercury Balance'.ljust(20)}#{'Ledger Balance'.ljust(20)}#{'Difference'.ljust(15)}Status"
|
59
61
|
puts "-" * 80
|
60
|
-
|
61
|
-
|
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 =
|
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,
|
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
|
-
|
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.
|
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(
|
144
|
-
|
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
|
-
|
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 =
|
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
|
-
#
|
207
|
-
|
208
|
-
|
209
|
-
|
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 =
|
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
|
-
|
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 =
|
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 =
|
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 |
|
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
|
data/lib/mercury_banking.rb
CHANGED
@@ -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
|
data/mercury_banking.gemspec
CHANGED
@@ -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 =
|
10
|
-
spec.description =
|
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
|
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 '
|
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 '
|
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
|