mercury_banking 0.5.37 → 0.6.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/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 -22
- data/lib/mercury_banking/cli/accounts.rb +24 -24
- data/lib/mercury_banking/cli/base.rb +13 -28
- data/lib/mercury_banking/cli/financials.rb +247 -195
- 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 +74 -61
- data/lib/mercury_banking/cli.rb +51 -49
- 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 -0
- data/mercury_banking.gemspec +15 -12
- metadata +39 -38
data/lib/mercury_banking/cli.rb
CHANGED
@@ -1,3 +1,5 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
1
3
|
require 'mercury_banking/cli/base'
|
2
4
|
require 'mercury_banking/cli/accounts'
|
3
5
|
require 'mercury_banking/cli/reports'
|
@@ -20,90 +22,91 @@ module MercuryBanking
|
|
20
22
|
include MercuryBanking::Formatters::TableFormatter
|
21
23
|
include MercuryBanking::Formatters::ExportFormatter
|
22
24
|
include MercuryBanking::Reports::BalanceSheet
|
23
|
-
|
25
|
+
|
24
26
|
# Add global option for JSON output
|
25
27
|
class_option :json, type: :boolean, default: false, desc: 'Output in JSON format'
|
26
|
-
|
28
|
+
|
27
29
|
map %w[--version -v] => :version
|
28
|
-
|
30
|
+
|
29
31
|
# Add version banner to help output
|
30
32
|
def self.banner(command, _namespace = nil, _subcommand = false)
|
31
33
|
"#{basename} #{command.usage}"
|
32
34
|
end
|
33
|
-
|
35
|
+
|
34
36
|
def self.help(shell, _subcommand = false)
|
35
37
|
shell.say "Mercury Banking CLI v#{MercuryBanking::VERSION} - Command-line interface for Mercury Banking API"
|
36
38
|
shell.say
|
37
39
|
super
|
38
40
|
end
|
39
|
-
|
41
|
+
|
40
42
|
# Handle Thor deprecation warning
|
41
43
|
def self.exit_on_failure?
|
42
44
|
true
|
43
45
|
end
|
44
|
-
|
46
|
+
|
45
47
|
desc 'version', 'Display Mercury Banking CLI version'
|
46
48
|
def version
|
47
49
|
if options[:json]
|
48
50
|
puts JSON.pretty_generate({
|
49
|
-
|
50
|
-
|
51
|
-
|
52
|
-
|
51
|
+
'name' => 'mercury-banking',
|
52
|
+
'version' => MercuryBanking::VERSION,
|
53
|
+
'ruby_version' => RUBY_VERSION
|
54
|
+
})
|
53
55
|
else
|
54
56
|
puts "Mercury Banking CLI v#{MercuryBanking::VERSION} (Ruby #{RUBY_VERSION})"
|
55
57
|
end
|
56
58
|
end
|
57
|
-
|
59
|
+
|
58
60
|
desc 'set_key', 'Sets and encrypts the API key'
|
59
61
|
def set_key
|
60
62
|
# Create the .mercury-banking directory if it doesn't exist
|
61
63
|
config_dir = File.join(Dir.home, '.mercury-banking')
|
62
64
|
FileUtils.mkdir_p(config_dir)
|
63
|
-
|
65
|
+
|
64
66
|
# Generate a random key and IV for encryption
|
65
67
|
key = SecureRandom.random_bytes(32)
|
66
68
|
iv = SecureRandom.random_bytes(16)
|
67
|
-
|
69
|
+
|
68
70
|
# Create a cipher config
|
69
71
|
cipher_config = {
|
70
72
|
key: Base64.strict_encode64(key),
|
71
73
|
iv: Base64.strict_encode64(iv),
|
72
74
|
cipher_name: 'aes-256-cbc'
|
73
75
|
}
|
74
|
-
|
76
|
+
|
75
77
|
# Save the cipher config to a file
|
76
78
|
key_config_path = File.join(config_dir, 'key_config.json')
|
77
79
|
File.write(key_config_path, JSON.pretty_generate(cipher_config))
|
78
|
-
|
80
|
+
|
79
81
|
# Initialize the SymmetricEncryption with the generated cipher
|
80
82
|
cipher = SymmetricEncryption::Cipher.new(
|
81
83
|
key: key,
|
82
84
|
iv: iv,
|
83
85
|
cipher_name: 'aes-256-cbc'
|
84
86
|
)
|
85
|
-
|
87
|
+
|
86
88
|
# Set the cipher as the primary one
|
87
89
|
SymmetricEncryption.cipher = cipher
|
88
|
-
|
90
|
+
|
89
91
|
# Get the API key from the user
|
90
92
|
api_key = ask('Enter your Mercury API key:')
|
91
|
-
|
93
|
+
|
92
94
|
# Encrypt the API key
|
93
95
|
encrypted_key = cipher.encrypt(api_key)
|
94
|
-
|
96
|
+
|
95
97
|
# Save the encrypted API key to a file
|
96
98
|
api_key_path = File.join(config_dir, 'api_key.enc')
|
97
99
|
File.write(api_key_path, encrypted_key)
|
98
|
-
|
100
|
+
|
99
101
|
puts "API key encrypted and saved to #{api_key_path}"
|
100
102
|
end
|
101
|
-
|
103
|
+
|
102
104
|
desc 'transactions ACCOUNT_ID_OR_NUMBER', 'List transactions for an account with their status'
|
103
105
|
method_option :start, type: :string, default: '2020-01-01', desc: 'Start date for transactions (YYYY-MM-DD)'
|
104
106
|
method_option :end, type: :string, desc: 'End date for transactions (YYYY-MM-DD)'
|
105
107
|
method_option :status, type: :string, desc: 'Filter by transaction status (e.g., sent, failed, pending)'
|
106
|
-
method_option :format, type: :string, default: 'table', enum: [
|
108
|
+
method_option :format, type: :string, default: 'table', enum: %w[table json],
|
109
|
+
desc: 'Output format (table or json)'
|
107
110
|
method_option :search, type: :string, desc: 'Search for transactions by description'
|
108
111
|
def transactions(account_identifier)
|
109
112
|
with_api_client do |client|
|
@@ -113,36 +116,34 @@ module MercuryBanking
|
|
113
116
|
begin
|
114
117
|
account = client.find_account_by_number(account_identifier)
|
115
118
|
account_id = account["id"]
|
116
|
-
rescue
|
117
|
-
#
|
118
|
-
|
119
|
-
|
119
|
+
rescue StandardError
|
120
|
+
# Handle error without assigning to unused variable
|
121
|
+
puts "Error: Could not find account with identifier #{account_identifier}"
|
122
|
+
return []
|
120
123
|
end
|
121
124
|
else
|
122
125
|
account_id = account_identifier
|
123
126
|
account = client.get_account(account_id)
|
124
127
|
end
|
125
|
-
|
128
|
+
|
126
129
|
# Get transactions for the account
|
127
130
|
start_date = options[:start]
|
128
131
|
end_date = options[:end]
|
129
|
-
|
132
|
+
|
130
133
|
transactions = client.get_transactions(account_id, start_date)
|
131
|
-
|
134
|
+
|
132
135
|
# Filter by end date if specified
|
133
136
|
if end_date
|
134
137
|
end_date_obj = Date.parse(end_date)
|
135
138
|
transactions = transactions.select do |t|
|
136
|
-
transaction_date =
|
139
|
+
transaction_date = Date.parse(t["postedAt"] || t["createdAt"])
|
137
140
|
transaction_date <= end_date_obj
|
138
141
|
end
|
139
142
|
end
|
140
|
-
|
143
|
+
|
141
144
|
# Filter by status if specified
|
142
|
-
if options[:status]
|
143
|
-
|
144
|
-
end
|
145
|
-
|
145
|
+
transactions = transactions.select { |t| t["status"] == options[:status] } if options[:status]
|
146
|
+
|
146
147
|
# Filter by search term if specified
|
147
148
|
if options[:search]
|
148
149
|
search_term = options[:search].downcase
|
@@ -151,7 +152,7 @@ module MercuryBanking
|
|
151
152
|
description.downcase.include?(search_term)
|
152
153
|
end
|
153
154
|
end
|
154
|
-
|
155
|
+
|
155
156
|
if options[:json] || options[:format] == 'json'
|
156
157
|
# Format transactions for JSON output
|
157
158
|
formatted_transactions = transactions.map do |t|
|
@@ -167,21 +168,21 @@ module MercuryBanking
|
|
167
168
|
'reason_for_failure' => t["reasonForFailure"]
|
168
169
|
}
|
169
170
|
end
|
170
|
-
|
171
|
+
|
171
172
|
puts JSON.pretty_generate({
|
172
|
-
|
173
|
-
|
174
|
-
|
175
|
-
|
176
|
-
|
177
|
-
|
173
|
+
'account_id' => account_id,
|
174
|
+
'account_name' => account['name'],
|
175
|
+
'account_number' => account['accountNumber'],
|
176
|
+
'total_transactions' => transactions.size,
|
177
|
+
'transactions' => formatted_transactions
|
178
|
+
})
|
178
179
|
else
|
179
180
|
# Display transactions in a table
|
180
181
|
puts "Transactions for #{account['name']} (#{account['accountNumber']})"
|
181
182
|
puts "Period: #{start_date} to #{end_date || 'present'}"
|
182
183
|
puts "Total transactions: #{transactions.size}"
|
183
184
|
puts
|
184
|
-
|
185
|
+
|
185
186
|
rows = transactions.map do |t|
|
186
187
|
date = t["postedAt"] ? Time.parse(t["postedAt"]).strftime("%Y-%m-%d") : Time.parse(t["createdAt"]).strftime("%Y-%m-%d")
|
187
188
|
description = t["bankDescription"] || t["externalMemo"] || "Unknown transaction"
|
@@ -191,19 +192,20 @@ module MercuryBanking
|
|
191
192
|
kind = t["kind"]
|
192
193
|
reconciled = false
|
193
194
|
failure_reason = t["reasonForFailure"] || ""
|
194
|
-
|
195
|
+
|
195
196
|
[t["id"], date, description, amount, status, kind, reconciled, failure_reason]
|
196
197
|
end
|
197
|
-
|
198
|
+
|
198
199
|
table = ::Terminal::Table.new(
|
199
|
-
headings: ['Transaction ID', 'Date', 'Description', 'Amount', 'Status', 'Type', 'Reconciled',
|
200
|
+
headings: ['Transaction ID', 'Date', 'Description', 'Amount', 'Status', 'Type', 'Reconciled',
|
201
|
+
'Failure Reason'],
|
200
202
|
rows: rows
|
201
203
|
)
|
202
|
-
|
204
|
+
|
203
205
|
puts table
|
204
206
|
end
|
205
207
|
end
|
206
208
|
end
|
207
209
|
end
|
208
210
|
end
|
209
|
-
end
|
211
|
+
end
|
@@ -1,3 +1,5 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
1
3
|
module MercuryBanking
|
2
4
|
module Formatters
|
3
5
|
# Formatter for exporting transactions to different formats
|
@@ -8,7 +10,7 @@ module MercuryBanking
|
|
8
10
|
file.puts "; Mercury Bank Transactions Export"
|
9
11
|
file.puts "; Generated on #{Time.now.strftime('%Y-%m-%d %H:%M:%S')}"
|
10
12
|
file.puts
|
11
|
-
|
13
|
+
|
12
14
|
# Add account declarations to ensure ledger recognizes them
|
13
15
|
accounts = transactions.map { |t| t["accountName"] || "Mercury Account" }.uniq
|
14
16
|
accounts.each do |account|
|
@@ -17,7 +19,7 @@ module MercuryBanking
|
|
17
19
|
file.puts "account Expenses:Unknown"
|
18
20
|
file.puts "account Income:Unknown"
|
19
21
|
file.puts
|
20
|
-
|
22
|
+
|
21
23
|
# Debug: Show what accounts are being created
|
22
24
|
if verbose
|
23
25
|
puts "Creating ledger file with the following accounts:"
|
@@ -25,13 +27,13 @@ module MercuryBanking
|
|
25
27
|
puts "- Assets:#{account}"
|
26
28
|
end
|
27
29
|
end
|
28
|
-
|
30
|
+
|
29
31
|
# Filter out failed transactions
|
30
32
|
valid_transactions = transactions.reject { |t| t["status"] == "failed" }
|
31
|
-
|
33
|
+
|
32
34
|
# Sort transactions chronologically by timestamp
|
33
35
|
sorted_transactions = sort_transactions_chronologically(valid_transactions)
|
34
|
-
|
36
|
+
|
35
37
|
sorted_transactions.each do |t|
|
36
38
|
# Get the full timestamp for precise ordering
|
37
39
|
timestamp = t["postedAt"] || t["createdAt"]
|
@@ -41,63 +43,60 @@ module MercuryBanking
|
|
41
43
|
amount = t["amount"].to_f
|
42
44
|
account_name = t["accountName"] || "Mercury Account"
|
43
45
|
transaction_id = t["id"]
|
44
|
-
|
46
|
+
|
45
47
|
# Check if this transaction is reconciled
|
46
48
|
is_reconciled = reconciled_transactions.include?(transaction_id)
|
47
|
-
|
49
|
+
|
48
50
|
# Sanitize description for ledger format - replace newlines with spaces and escape semicolons
|
49
51
|
description = description.gsub(/[\r\n]+/, ' ').gsub(/;/, '\\;')
|
50
|
-
|
52
|
+
|
51
53
|
# Ensure the description doesn't start with characters that could be interpreted as a date
|
52
54
|
# or other ledger syntax elements
|
53
|
-
if description =~ /^\d/ || description =~ /^[v#]/ || description =~ /^\s*\d{4}/ || description =~
|
55
|
+
if description =~ /^\d/ || description =~ /^[v#]/ || description =~ /^\s*\d{4}/ || description =~ %r{^\s*\d{2}/\d{2}}
|
54
56
|
description = "- #{description}"
|
55
57
|
end
|
56
|
-
|
58
|
+
|
57
59
|
# Format the transaction with proper indentation and alignment
|
58
60
|
file.puts "#{date} #{is_reconciled ? '* ' : ''}#{description}"
|
59
61
|
file.puts " ; Transaction ID: #{transaction_id}"
|
60
62
|
file.puts " ; Timestamp: #{timestamp}"
|
61
63
|
file.puts " ; Time: #{time}"
|
62
|
-
file.puts " ; Status: #{t[
|
63
|
-
|
64
|
+
file.puts " ; Status: #{t['status']}"
|
65
|
+
|
64
66
|
# Add reconciliation metadata
|
65
|
-
if is_reconciled
|
66
|
-
|
67
|
-
end
|
68
|
-
|
67
|
+
file.puts " ; :reconciled: true" if is_reconciled
|
68
|
+
|
69
69
|
# Add counterparty information if available
|
70
70
|
if t["counterpartyName"]
|
71
71
|
# Sanitize counterparty name
|
72
72
|
counterparty = t["counterpartyName"].gsub(/[\r\n]+/, ' ').gsub(/;/, '\\;')
|
73
73
|
file.puts " ; Counterparty: #{counterparty}"
|
74
74
|
end
|
75
|
-
|
75
|
+
|
76
76
|
# Add additional metadata if available
|
77
77
|
if t["externalMemo"] && t["externalMemo"] != description
|
78
78
|
# Sanitize memo
|
79
79
|
memo = t["externalMemo"].gsub(/[\r\n]+/, ' ').gsub(/;/, '\\;')
|
80
80
|
file.puts " ; Memo: #{memo}"
|
81
81
|
end
|
82
|
-
|
82
|
+
|
83
83
|
# Add the postings
|
84
|
-
|
84
|
+
file.puts " Assets:#{account_name} $#{format('%.2f', amount)}"
|
85
|
+
if amount.positive?
|
85
86
|
# Credit (money coming in)
|
86
|
-
file.puts " Assets:#{account_name} $#{format('%.2f', amount)}"
|
87
87
|
file.puts " Income:Unknown $#{format('%.2f', -amount)}"
|
88
88
|
else
|
89
89
|
# Debit (money going out)
|
90
|
-
file.puts " Assets:#{account_name} $#{format('%.2f', amount)}"
|
91
90
|
file.puts " Expenses:Unknown $#{format('%.2f', amount.abs)}"
|
92
91
|
end
|
93
92
|
file.puts
|
94
93
|
end
|
95
94
|
end
|
96
|
-
|
95
|
+
|
97
96
|
puts "Verifying ledger file validity..."
|
98
97
|
verify_ledger_file(output_file, verbose)
|
99
98
|
end
|
100
|
-
|
99
|
+
|
101
100
|
# Sort transactions chronologically by timestamp
|
102
101
|
def sort_transactions_chronologically(transactions)
|
103
102
|
transactions.sort_by do |t|
|
@@ -106,65 +105,68 @@ module MercuryBanking
|
|
106
105
|
Time.parse(timestamp)
|
107
106
|
end
|
108
107
|
end
|
109
|
-
|
108
|
+
|
110
109
|
# Verify that the ledger file is valid
|
111
110
|
def verify_ledger_file(output_file, verbose = false)
|
112
|
-
|
113
|
-
|
114
|
-
|
115
|
-
|
116
|
-
|
117
|
-
if
|
118
|
-
|
119
|
-
|
120
|
-
|
121
|
-
|
122
|
-
|
123
|
-
|
124
|
-
|
125
|
-
|
126
|
-
|
127
|
-
|
128
|
-
|
129
|
-
|
130
|
-
puts "This may indicate a problem with the file format, but the balance command succeeded."
|
131
|
-
end
|
111
|
+
# Debug: Verify the ledger file is valid
|
112
|
+
cmd = "ledger -f #{output_file} balance"
|
113
|
+
output = `#{cmd}`
|
114
|
+
|
115
|
+
if $?.success?
|
116
|
+
if verbose
|
117
|
+
puts "Ledger verification output: #{output}"
|
118
|
+
|
119
|
+
# Show all accounts in the ledger file
|
120
|
+
cmd = "ledger -f #{output_file} accounts"
|
121
|
+
output = `#{cmd}`
|
122
|
+
|
123
|
+
if $?.success?
|
124
|
+
puts "All accounts in the ledger file:"
|
125
|
+
puts output
|
126
|
+
else
|
127
|
+
puts "Warning: Could not list accounts in the ledger file."
|
128
|
+
puts "This may indicate a problem with the file format, but the balance command succeeded."
|
132
129
|
end
|
133
|
-
else
|
134
|
-
puts "Warning: Ledger verification failed. The file may contain formatting issues."
|
135
|
-
puts "Error output: #{output}"
|
136
|
-
|
137
|
-
# Try to identify problematic lines
|
138
|
-
cmd = "grep -n '^[0-9]' #{output_file} | head -10"
|
139
|
-
problematic_lines = `#{cmd}`
|
140
|
-
puts "First few transaction lines for inspection:"
|
141
|
-
puts problematic_lines
|
142
130
|
end
|
143
|
-
|
144
|
-
puts "
|
145
|
-
puts "
|
131
|
+
else
|
132
|
+
puts "Warning: Ledger verification failed. The file may contain formatting issues."
|
133
|
+
puts "Error output: #{output}"
|
134
|
+
|
135
|
+
# Try to identify problematic lines
|
136
|
+
cmd = "grep -n '^[0-9]' #{output_file} | head -10"
|
137
|
+
problematic_lines = `#{cmd}`
|
138
|
+
puts "First few transaction lines for inspection:"
|
139
|
+
puts problematic_lines
|
146
140
|
end
|
141
|
+
rescue StandardError => e
|
142
|
+
puts "Error during ledger verification: #{e.message}"
|
143
|
+
puts "This doesn't necessarily mean the file is invalid, but there may be issues with the ledger command."
|
147
144
|
end
|
148
|
-
|
145
|
+
|
149
146
|
# Export transactions to beancount format
|
150
147
|
def export_to_beancount(transactions, output_file, reconciled_transactions = [], verbose = false)
|
151
148
|
File.open(output_file, 'w') do |file|
|
152
149
|
file.puts "; Mercury Bank Transactions Export"
|
153
150
|
file.puts "; Generated on #{Time.now.strftime('%Y-%m-%d %H:%M:%S')}"
|
154
151
|
file.puts
|
155
|
-
|
152
|
+
|
156
153
|
# Filter out failed transactions
|
157
154
|
valid_transactions = transactions.reject { |t| t["status"] == "failed" }
|
158
|
-
|
155
|
+
|
159
156
|
# Get unique account names for debug output
|
160
157
|
if verbose
|
161
|
-
|
158
|
+
# First get all account names
|
159
|
+
account_names = valid_transactions.map do |t|
|
160
|
+
t["accountName"] || "Mercury"
|
161
|
+
end
|
162
|
+
# Then clean and make unique
|
163
|
+
accounts = account_names.map { |name| name.gsub(/[^a-zA-Z0-9]/, '-') }.uniq
|
162
164
|
puts "Creating beancount file with the following accounts:"
|
163
165
|
accounts.each do |account|
|
164
166
|
puts "- Assets:#{account}"
|
165
167
|
end
|
166
168
|
end
|
167
|
-
|
169
|
+
|
168
170
|
valid_transactions.each do |t|
|
169
171
|
date = t["postedAt"] ? Time.parse(t["postedAt"]).strftime("%Y-%m-%d") : Time.parse(t["createdAt"]).strftime("%Y-%m-%d")
|
170
172
|
description = t["bankDescription"] || t["externalMemo"] || "Unknown transaction"
|
@@ -172,18 +174,18 @@ module MercuryBanking
|
|
172
174
|
account_name = t["accountName"] || "Mercury"
|
173
175
|
account_name = account_name.gsub(/[^a-zA-Z0-9]/, '-')
|
174
176
|
transaction_id = t["id"]
|
175
|
-
|
177
|
+
|
176
178
|
# Check if this transaction is reconciled
|
177
179
|
is_reconciled = reconciled_transactions.include?(transaction_id)
|
178
180
|
flag = is_reconciled ? "*" : "!"
|
179
|
-
|
181
|
+
|
180
182
|
# Sanitize description for beancount format
|
181
183
|
description = description.gsub(/[\r\n]+/, ' ').gsub(/"/, '\\"')
|
182
|
-
|
184
|
+
|
183
185
|
file.puts "#{date} #{flag} \"#{description}\""
|
184
186
|
file.puts " ; Transaction ID: #{transaction_id}"
|
185
|
-
file.puts " ; Status: #{t[
|
186
|
-
|
187
|
+
file.puts " ; Status: #{t['status']}"
|
188
|
+
|
187
189
|
# Add reconciliation metadata
|
188
190
|
if is_reconciled
|
189
191
|
file.puts " reconciled: \"true\""
|
@@ -192,29 +194,29 @@ module MercuryBanking
|
|
192
194
|
file.puts " reconciled: \"false\""
|
193
195
|
file.puts " reconciled_at: \"\""
|
194
196
|
end
|
195
|
-
|
196
|
-
if amount
|
197
|
-
file.puts " Expenses:Unknown #{format(
|
198
|
-
file.puts " Assets:#{account_name} #{format(
|
197
|
+
|
198
|
+
if amount.negative?
|
199
|
+
file.puts " Expenses:Unknown #{format('%.2f', amount.abs)} USD"
|
200
|
+
file.puts " Assets:#{account_name} #{format('%.2f', amount)} USD"
|
199
201
|
else
|
200
|
-
file.puts " Assets:#{account_name} #{format(
|
201
|
-
file.puts " Income:Unknown #{format(
|
202
|
+
file.puts " Assets:#{account_name} #{format('%.2f', amount)} USD"
|
203
|
+
file.puts " Income:Unknown #{format('%.2f', -amount)} USD"
|
202
204
|
end
|
203
205
|
file.puts
|
204
206
|
end
|
205
207
|
end
|
206
208
|
end
|
207
|
-
|
209
|
+
|
208
210
|
# Export transactions to hledger format
|
209
211
|
def export_to_hledger(transactions, output_file, reconciled_transactions = [], verbose = false)
|
210
212
|
File.open(output_file, 'w') do |file|
|
211
213
|
file.puts "; Mercury Bank Transactions Export"
|
212
214
|
file.puts "; Generated on #{Time.now.strftime('%Y-%m-%d %H:%M:%S')}"
|
213
215
|
file.puts
|
214
|
-
|
216
|
+
|
215
217
|
# Filter out failed transactions
|
216
218
|
valid_transactions = transactions.reject { |t| t["status"] == "failed" }
|
217
|
-
|
219
|
+
|
218
220
|
# Get unique account names for debug output
|
219
221
|
if verbose
|
220
222
|
accounts = valid_transactions.map { |t| t["accountName"] || "Mercury Account" }.uniq
|
@@ -223,25 +225,25 @@ module MercuryBanking
|
|
223
225
|
puts "- Assets:#{account}"
|
224
226
|
end
|
225
227
|
end
|
226
|
-
|
228
|
+
|
227
229
|
valid_transactions.each do |t|
|
228
230
|
date = t["postedAt"] ? Time.parse(t["postedAt"]).strftime("%Y-%m-%d") : Time.parse(t["createdAt"]).strftime("%Y-%m-%d")
|
229
231
|
description = t["bankDescription"] || t["externalMemo"] || "Unknown transaction"
|
230
232
|
amount = t["amount"].to_f
|
231
233
|
account_name = t["accountName"] || "Mercury Account"
|
232
234
|
transaction_id = t["id"]
|
233
|
-
|
235
|
+
|
234
236
|
# Check if this transaction is reconciled
|
235
237
|
is_reconciled = reconciled_transactions.include?(transaction_id)
|
236
238
|
status_marker = is_reconciled ? " * " : " ! "
|
237
|
-
|
239
|
+
|
238
240
|
# Sanitize description for hledger format
|
239
241
|
description = description.gsub(/[\r\n]+/, ' ').gsub(/;/, '\\;')
|
240
|
-
|
242
|
+
|
241
243
|
file.puts "#{date}#{status_marker}#{description}"
|
242
244
|
file.puts " ; Transaction ID: #{transaction_id}"
|
243
|
-
file.puts " ; Status: #{t[
|
244
|
-
|
245
|
+
file.puts " ; Status: #{t['status']}"
|
246
|
+
|
245
247
|
# Add reconciliation metadata
|
246
248
|
if is_reconciled
|
247
249
|
file.puts " reconciled: true"
|
@@ -250,57 +252,57 @@ module MercuryBanking
|
|
250
252
|
file.puts " reconciled: false"
|
251
253
|
file.puts " reconciled-at:"
|
252
254
|
end
|
253
|
-
|
254
|
-
if amount
|
255
|
-
file.puts " Expenses:Unknown $#{format(
|
256
|
-
file.puts " Assets:#{account_name} $#{format(
|
255
|
+
|
256
|
+
if amount.negative?
|
257
|
+
file.puts " Expenses:Unknown $#{format('%.2f', amount.abs)}"
|
258
|
+
file.puts " Assets:#{account_name} $#{format('%.2f', amount)}"
|
257
259
|
else
|
258
|
-
file.puts " Assets:#{account_name} $#{format(
|
259
|
-
file.puts " Income:Unknown $#{format(
|
260
|
+
file.puts " Assets:#{account_name} $#{format('%.2f', amount)}"
|
261
|
+
file.puts " Income:Unknown $#{format('%.2f', -amount)}"
|
260
262
|
end
|
261
263
|
file.puts
|
262
264
|
end
|
263
265
|
end
|
264
266
|
end
|
265
|
-
|
267
|
+
|
266
268
|
# Export transactions to CSV format
|
267
269
|
def export_to_csv(transactions, output_file, reconciled_transactions = [], verbose = false)
|
268
270
|
require 'csv'
|
269
|
-
|
271
|
+
|
270
272
|
CSV.open(output_file, 'w') do |csv|
|
271
273
|
# Write header
|
272
274
|
csv << ['Date', 'Description', 'Amount', 'Account', 'Transaction ID', 'Status', 'Reconciled', 'Reconciled At']
|
273
|
-
|
275
|
+
|
274
276
|
# Filter out failed transactions
|
275
277
|
valid_transactions = transactions.reject { |t| t["status"] == "failed" }
|
276
|
-
|
278
|
+
|
277
279
|
if verbose
|
278
280
|
puts "Creating CSV file with #{valid_transactions.size} transactions"
|
279
281
|
puts "CSV columns: Date, Description, Amount, Account, Transaction ID, Status, Reconciled, Reconciled At"
|
280
282
|
end
|
281
|
-
|
283
|
+
|
282
284
|
# Write transactions
|
283
285
|
valid_transactions.each do |t|
|
284
286
|
date = t["postedAt"] ? Time.parse(t["postedAt"]).strftime("%Y-%m-%d") : Time.parse(t["createdAt"]).strftime("%Y-%m-%d")
|
285
287
|
description = t["bankDescription"] || t["externalMemo"] || "Unknown transaction"
|
286
|
-
|
288
|
+
|
287
289
|
# Replace newlines with spaces for CSV format
|
288
290
|
description = description.gsub(/[\r\n]+/, ' ')
|
289
|
-
|
291
|
+
|
290
292
|
amount = t["amount"].to_f
|
291
293
|
account_name = t["accountName"] || "Mercury Account"
|
292
294
|
transaction_id = t["id"]
|
293
295
|
status = t["status"]
|
294
|
-
|
296
|
+
|
295
297
|
# Check if this transaction is reconciled
|
296
298
|
is_reconciled = reconciled_transactions.include?(transaction_id)
|
297
299
|
reconciled = is_reconciled ? "Yes" : "No"
|
298
300
|
reconciled_at = is_reconciled ? Time.now.strftime("%Y-%m-%d") : ""
|
299
|
-
|
301
|
+
|
300
302
|
csv << [date, description, amount, account_name, transaction_id, status, reconciled, reconciled_at]
|
301
303
|
end
|
302
304
|
end
|
303
305
|
end
|
304
306
|
end
|
305
307
|
end
|
306
|
-
end
|
308
|
+
end
|