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.
- checksums.yaml +7 -0
- data/.env_test +1 -0
- data/.gitignore +24 -0
- data/.rspec +3 -0
- data/.rubocop.yml +17 -0
- data/.travis.yml +6 -0
- data/CHANGELOG.md +90 -0
- data/CODE_OF_CONDUCT.md +74 -0
- data/Gemfile +26 -0
- data/Gemfile.lock +140 -0
- data/LICENSE +21 -0
- data/LINTING_REPORT.md +118 -0
- data/README.md +244 -0
- data/Rakefile +6 -0
- data/bin/console +14 -0
- data/bin/mercury +17 -0
- data/bin/setup +8 -0
- data/lib/mercury_banking/api.rb +184 -0
- data/lib/mercury_banking/cli/accounts.rb +61 -0
- data/lib/mercury_banking/cli/base.rb +68 -0
- data/lib/mercury_banking/cli/financials.rb +302 -0
- data/lib/mercury_banking/cli/reconciliation.rb +406 -0
- data/lib/mercury_banking/cli/reports.rb +265 -0
- data/lib/mercury_banking/cli/transactions.rb +222 -0
- data/lib/mercury_banking/cli.rb +209 -0
- data/lib/mercury_banking/formatters/export_formatter.rb +306 -0
- data/lib/mercury_banking/formatters/table_formatter.rb +133 -0
- data/lib/mercury_banking/multi.rb +135 -0
- data/lib/mercury_banking/recipient.rb +29 -0
- data/lib/mercury_banking/reconciliation.rb +139 -0
- data/lib/mercury_banking/reports/balance_sheet.rb +586 -0
- data/lib/mercury_banking/reports/reconciliation.rb +307 -0
- data/lib/mercury_banking/utils/command_utils.rb +18 -0
- data/lib/mercury_banking/version.rb +3 -0
- data/lib/mercury_banking.rb +19 -0
- data/mercury_banking.gemspec +37 -0
- metadata +183 -0
@@ -0,0 +1,222 @@
|
|
1
|
+
module MercuryBanking
|
2
|
+
module CLI
|
3
|
+
# Module for transaction-related commands
|
4
|
+
module Transactions
|
5
|
+
# Add transaction-related commands to the CLI class
|
6
|
+
def self.included(base)
|
7
|
+
base.class_eval do
|
8
|
+
include MercuryBanking::Formatters::ExportFormatter
|
9
|
+
|
10
|
+
desc 'transactions_download', 'Download all Mercury transactions as CSV files'
|
11
|
+
method_option :output_dir, type: :string, default: 'transactions', desc: 'Directory to save transaction files'
|
12
|
+
method_option :start_date, type: :string, default: '2020-01-01', desc: 'Start date for transactions (YYYY-MM-DD)'
|
13
|
+
method_option :end_date, type: :string, desc: 'End date for transactions (YYYY-MM-DD)'
|
14
|
+
method_option :format, type: :string, default: 'csv', enum: ['csv', 'beancount', 'ledger', 'all'], desc: 'Output format (csv, beancount, ledger, or all)'
|
15
|
+
method_option :verbose, type: :boolean, default: false, desc: 'Show detailed debug information'
|
16
|
+
def transactions_download
|
17
|
+
with_api_client do |client|
|
18
|
+
# Create output directory if it doesn't exist
|
19
|
+
output_dir = options[:output_dir]
|
20
|
+
FileUtils.mkdir_p(output_dir)
|
21
|
+
|
22
|
+
# Get all accounts
|
23
|
+
accounts = client.accounts
|
24
|
+
|
25
|
+
# Get start and end dates
|
26
|
+
start_date = options[:start_date]
|
27
|
+
end_date = options[:end_date]
|
28
|
+
|
29
|
+
# Get format
|
30
|
+
format = options[:format]
|
31
|
+
|
32
|
+
# Get verbose option
|
33
|
+
verbose = options[:verbose]
|
34
|
+
|
35
|
+
# For each account, get transactions and save to file
|
36
|
+
accounts.each do |account|
|
37
|
+
account_id = account["id"]
|
38
|
+
account_name = account["name"]
|
39
|
+
|
40
|
+
puts "Fetching transactions for #{account_name} (#{account_id})..."
|
41
|
+
|
42
|
+
# Get all transactions for this account
|
43
|
+
transactions = client.get_transactions(account_id, start_date)
|
44
|
+
|
45
|
+
# Filter by end date if specified
|
46
|
+
if end_date
|
47
|
+
end_date_obj = Date.parse(end_date)
|
48
|
+
transactions = transactions.select do |t|
|
49
|
+
transaction_date = t["postedAt"] ? Date.parse(t["postedAt"]) : Date.parse(t["createdAt"])
|
50
|
+
transaction_date <= end_date_obj
|
51
|
+
end
|
52
|
+
end
|
53
|
+
|
54
|
+
if transactions.empty?
|
55
|
+
puts "No transactions found for #{account_name}."
|
56
|
+
next
|
57
|
+
end
|
58
|
+
|
59
|
+
# Group transactions by month
|
60
|
+
transactions_by_month = {}
|
61
|
+
|
62
|
+
transactions.each do |t|
|
63
|
+
# Get the month from the transaction date
|
64
|
+
date = t["postedAt"] ? Date.parse(t["postedAt"]) : Date.parse(t["createdAt"])
|
65
|
+
month_key = "#{date.year}-#{date.month.to_s.rjust(2, '0')}"
|
66
|
+
|
67
|
+
# Initialize the month array if it doesn't exist
|
68
|
+
transactions_by_month[month_key] ||= []
|
69
|
+
|
70
|
+
# Add the transaction to the month array
|
71
|
+
transactions_by_month[month_key] << t
|
72
|
+
end
|
73
|
+
|
74
|
+
# For each month, save transactions to file
|
75
|
+
transactions_by_month.each do |month, month_transactions|
|
76
|
+
# Use the full account number for the filename
|
77
|
+
account_number = account["accountNumber"]
|
78
|
+
|
79
|
+
# Create filenames for different formats
|
80
|
+
csv_filename = File.join(output_dir, "#{month}-Mercury-#{account_number}.csv")
|
81
|
+
beancount_filename = File.join(output_dir, "#{month}-Mercury-#{account_number}.beancount")
|
82
|
+
ledger_filename = File.join(output_dir, "#{month}-Mercury-#{account_number}.ledger")
|
83
|
+
|
84
|
+
# Export transactions in the requested format
|
85
|
+
case format
|
86
|
+
when 'csv'
|
87
|
+
export_to_csv(month_transactions, csv_filename, [], verbose)
|
88
|
+
puts "Exported #{month_transactions.size} transactions to #{csv_filename}"
|
89
|
+
when 'beancount'
|
90
|
+
export_to_beancount(month_transactions, beancount_filename, [], verbose)
|
91
|
+
puts "Exported #{month_transactions.size} transactions to #{beancount_filename}"
|
92
|
+
when 'ledger'
|
93
|
+
export_to_ledger(month_transactions, ledger_filename, [], verbose)
|
94
|
+
puts "Exported #{month_transactions.size} transactions to #{ledger_filename}"
|
95
|
+
when 'all'
|
96
|
+
export_to_csv(month_transactions, csv_filename, [], verbose)
|
97
|
+
puts "Exported #{month_transactions.size} transactions to #{csv_filename}"
|
98
|
+
|
99
|
+
export_to_beancount(month_transactions, beancount_filename, [], verbose)
|
100
|
+
puts "Exported #{month_transactions.size} transactions to #{beancount_filename}"
|
101
|
+
|
102
|
+
export_to_ledger(month_transactions, ledger_filename, [], verbose)
|
103
|
+
puts "Exported #{month_transactions.size} transactions to #{ledger_filename}"
|
104
|
+
end
|
105
|
+
end
|
106
|
+
end
|
107
|
+
|
108
|
+
puts "\nTransaction export complete. Files saved to #{output_dir}/ directory."
|
109
|
+
end
|
110
|
+
end
|
111
|
+
|
112
|
+
desc "transactions ACCOUNT_ID_OR_NUMBER", "List transactions for an account with their status"
|
113
|
+
method_option :limit, type: :numeric, default: 10, desc: "Number of transactions to show"
|
114
|
+
method_option :start, type: :string, desc: "Start date (YYYY-MM-DD)"
|
115
|
+
method_option :end, type: :string, desc: "End date (YYYY-MM-DD)"
|
116
|
+
method_option :status, type: :string, desc: "Filter by status (pending, sent, complete, failed)"
|
117
|
+
method_option :json, type: :boolean, default: false, desc: "Output in JSON format"
|
118
|
+
method_option :csv, type: :string, desc: "Export to CSV file"
|
119
|
+
def transactions(account_id_or_number)
|
120
|
+
with_api_client do |client|
|
121
|
+
# Find the account by ID or account number
|
122
|
+
account = find_account(client, account_id_or_number)
|
123
|
+
|
124
|
+
if account.nil?
|
125
|
+
puts "Account not found: #{account_id_or_number}"
|
126
|
+
return
|
127
|
+
end
|
128
|
+
|
129
|
+
account_id = account["id"]
|
130
|
+
|
131
|
+
# Get transactions with optional date filters
|
132
|
+
start_date = options[:start]
|
133
|
+
end_date = options[:end]
|
134
|
+
|
135
|
+
puts "Fetching transactions for #{account["name"]}..."
|
136
|
+
transactions = client.get_transactions(account_id, start_date)
|
137
|
+
|
138
|
+
# Filter by end date if specified
|
139
|
+
if end_date
|
140
|
+
end_date_obj = Date.parse(end_date)
|
141
|
+
transactions = transactions.select do |t|
|
142
|
+
transaction_date = t["postedAt"] ? Date.parse(t["postedAt"]) : Date.parse(t["createdAt"])
|
143
|
+
transaction_date <= end_date_obj
|
144
|
+
end
|
145
|
+
end
|
146
|
+
|
147
|
+
# Filter by status if specified
|
148
|
+
if options[:status]
|
149
|
+
transactions = transactions.select { |t| t["status"] == options[:status] }
|
150
|
+
end
|
151
|
+
|
152
|
+
# Limit the number of transactions
|
153
|
+
limit = options[:limit]
|
154
|
+
transactions = transactions.take(limit) if limit > 0
|
155
|
+
|
156
|
+
# Export to CSV if requested
|
157
|
+
if options[:csv]
|
158
|
+
export_to_csv(transactions, options[:csv])
|
159
|
+
puts "Exported #{transactions.size} transactions to #{options[:csv]}"
|
160
|
+
return
|
161
|
+
end
|
162
|
+
|
163
|
+
# Output in JSON format if requested
|
164
|
+
if options[:json]
|
165
|
+
puts JSON.pretty_generate(transactions)
|
166
|
+
return
|
167
|
+
end
|
168
|
+
|
169
|
+
# Create a table for display
|
170
|
+
table = Terminal::Table.new
|
171
|
+
table.title = "Transactions for #{account["name"]}"
|
172
|
+
table.headings = ['Date', 'Description', 'Amount', 'Status']
|
173
|
+
|
174
|
+
transactions.each do |t|
|
175
|
+
date = t["postedAt"] ? Date.parse(t["postedAt"]) : Date.parse(t["createdAt"])
|
176
|
+
description = t["bankDescription"] || t["externalMemo"] || "Unknown"
|
177
|
+
amount = t["amount"]
|
178
|
+
status = t["status"]
|
179
|
+
|
180
|
+
table.add_row [date, description, "$#{amount}", status]
|
181
|
+
end
|
182
|
+
|
183
|
+
puts table
|
184
|
+
end
|
185
|
+
end
|
186
|
+
|
187
|
+
# Helper methods that should not be exposed as commands
|
188
|
+
no_commands do
|
189
|
+
# Helper method to sort transactions chronologically
|
190
|
+
def sort_transactions_chronologically(transactions)
|
191
|
+
transactions.sort_by do |t|
|
192
|
+
# Use postedAt if available, otherwise fall back to createdAt
|
193
|
+
timestamp = t["postedAt"] || t["createdAt"]
|
194
|
+
Time.parse(timestamp)
|
195
|
+
end
|
196
|
+
end
|
197
|
+
|
198
|
+
# Helper method to find an account by ID or account number
|
199
|
+
def find_account(client, account_id_or_number)
|
200
|
+
accounts = client.accounts
|
201
|
+
|
202
|
+
# Try to find by ID first
|
203
|
+
account = accounts.find { |a| a["id"] == account_id_or_number }
|
204
|
+
|
205
|
+
# If not found by ID, try by account number
|
206
|
+
if account.nil?
|
207
|
+
account = accounts.find { |a| a["accountNumber"] == account_id_or_number }
|
208
|
+
end
|
209
|
+
|
210
|
+
# If still not found, try by the last 4 digits of the account number
|
211
|
+
if account.nil?
|
212
|
+
account = accounts.find { |a| a["accountNumber"]&.end_with?(account_id_or_number) }
|
213
|
+
end
|
214
|
+
|
215
|
+
account
|
216
|
+
end
|
217
|
+
end
|
218
|
+
end
|
219
|
+
end
|
220
|
+
end
|
221
|
+
end
|
222
|
+
end
|
@@ -0,0 +1,209 @@
|
|
1
|
+
require 'mercury_banking/cli/base'
|
2
|
+
require 'mercury_banking/cli/accounts'
|
3
|
+
require 'mercury_banking/cli/reports'
|
4
|
+
require 'mercury_banking/cli/transactions'
|
5
|
+
require 'mercury_banking/cli/financials'
|
6
|
+
require 'mercury_banking/formatters/table_formatter'
|
7
|
+
require 'mercury_banking/formatters/export_formatter'
|
8
|
+
require 'mercury_banking/reports/balance_sheet'
|
9
|
+
|
10
|
+
module MercuryBanking
|
11
|
+
# CLI module for Mercury Banking
|
12
|
+
module CLI
|
13
|
+
# Main CLI class that includes all the modules
|
14
|
+
class Main < Thor
|
15
|
+
include MercuryBanking::CLI::Base
|
16
|
+
include MercuryBanking::CLI::Accounts
|
17
|
+
include MercuryBanking::CLI::Reports
|
18
|
+
include MercuryBanking::CLI::Transactions
|
19
|
+
include MercuryBanking::CLI::Financials
|
20
|
+
include MercuryBanking::Formatters::TableFormatter
|
21
|
+
include MercuryBanking::Formatters::ExportFormatter
|
22
|
+
include MercuryBanking::Reports::BalanceSheet
|
23
|
+
|
24
|
+
# Add global option for JSON output
|
25
|
+
class_option :json, type: :boolean, default: false, desc: 'Output in JSON format'
|
26
|
+
|
27
|
+
map %w[--version -v] => :version
|
28
|
+
|
29
|
+
# Add version banner to help output
|
30
|
+
def self.banner(command, _namespace = nil, _subcommand = false)
|
31
|
+
"#{basename} #{command.usage}"
|
32
|
+
end
|
33
|
+
|
34
|
+
def self.help(shell, _subcommand = false)
|
35
|
+
shell.say "Mercury Banking CLI v#{MercuryBanking::VERSION} - Command-line interface for Mercury Banking API"
|
36
|
+
shell.say
|
37
|
+
super
|
38
|
+
end
|
39
|
+
|
40
|
+
# Handle Thor deprecation warning
|
41
|
+
def self.exit_on_failure?
|
42
|
+
true
|
43
|
+
end
|
44
|
+
|
45
|
+
desc 'version', 'Display Mercury Banking CLI version'
|
46
|
+
def version
|
47
|
+
if options[:json]
|
48
|
+
puts JSON.pretty_generate({
|
49
|
+
'name' => 'mercury-banking',
|
50
|
+
'version' => MercuryBanking::VERSION,
|
51
|
+
'ruby_version' => RUBY_VERSION
|
52
|
+
})
|
53
|
+
else
|
54
|
+
puts "Mercury Banking CLI v#{MercuryBanking::VERSION} (Ruby #{RUBY_VERSION})"
|
55
|
+
end
|
56
|
+
end
|
57
|
+
|
58
|
+
desc 'set_key', 'Sets and encrypts the API key'
|
59
|
+
def set_key
|
60
|
+
# Create the .mercury-banking directory if it doesn't exist
|
61
|
+
config_dir = File.join(Dir.home, '.mercury-banking')
|
62
|
+
FileUtils.mkdir_p(config_dir)
|
63
|
+
|
64
|
+
# Generate a random key and IV for encryption
|
65
|
+
key = SecureRandom.random_bytes(32)
|
66
|
+
iv = SecureRandom.random_bytes(16)
|
67
|
+
|
68
|
+
# Create a cipher config
|
69
|
+
cipher_config = {
|
70
|
+
key: Base64.strict_encode64(key),
|
71
|
+
iv: Base64.strict_encode64(iv),
|
72
|
+
cipher_name: 'aes-256-cbc'
|
73
|
+
}
|
74
|
+
|
75
|
+
# Save the cipher config to a file
|
76
|
+
key_config_path = File.join(config_dir, 'key_config.json')
|
77
|
+
File.write(key_config_path, JSON.pretty_generate(cipher_config))
|
78
|
+
|
79
|
+
# Initialize the SymmetricEncryption with the generated cipher
|
80
|
+
cipher = SymmetricEncryption::Cipher.new(
|
81
|
+
key: key,
|
82
|
+
iv: iv,
|
83
|
+
cipher_name: 'aes-256-cbc'
|
84
|
+
)
|
85
|
+
|
86
|
+
# Set the cipher as the primary one
|
87
|
+
SymmetricEncryption.cipher = cipher
|
88
|
+
|
89
|
+
# Get the API key from the user
|
90
|
+
api_key = ask('Enter your Mercury API key:')
|
91
|
+
|
92
|
+
# Encrypt the API key
|
93
|
+
encrypted_key = cipher.encrypt(api_key)
|
94
|
+
|
95
|
+
# Save the encrypted API key to a file
|
96
|
+
api_key_path = File.join(config_dir, 'api_key.enc')
|
97
|
+
File.write(api_key_path, encrypted_key)
|
98
|
+
|
99
|
+
puts "API key encrypted and saved to #{api_key_path}"
|
100
|
+
end
|
101
|
+
|
102
|
+
desc 'transactions ACCOUNT_ID_OR_NUMBER', 'List transactions for an account with their status'
|
103
|
+
method_option :start, type: :string, default: '2020-01-01', desc: 'Start date for transactions (YYYY-MM-DD)'
|
104
|
+
method_option :end, type: :string, desc: 'End date for transactions (YYYY-MM-DD)'
|
105
|
+
method_option :status, type: :string, desc: 'Filter by transaction status (e.g., sent, failed, pending)'
|
106
|
+
method_option :format, type: :string, default: 'table', enum: ['table', 'json'], desc: 'Output format (table or json)'
|
107
|
+
method_option :search, type: :string, desc: 'Search for transactions by description'
|
108
|
+
def transactions(account_identifier)
|
109
|
+
with_api_client do |client|
|
110
|
+
# Determine if we're dealing with an account ID or account number
|
111
|
+
account_id = nil
|
112
|
+
if account_identifier.match?(/^\d+$/) && !account_identifier.include?('-')
|
113
|
+
begin
|
114
|
+
account = client.find_account_by_number(account_identifier)
|
115
|
+
account_id = account["id"]
|
116
|
+
rescue => e
|
117
|
+
# If not found by number, assume it's an ID
|
118
|
+
account_id = account_identifier
|
119
|
+
account = client.get_account(account_id)
|
120
|
+
end
|
121
|
+
else
|
122
|
+
account_id = account_identifier
|
123
|
+
account = client.get_account(account_id)
|
124
|
+
end
|
125
|
+
|
126
|
+
# Get transactions for the account
|
127
|
+
start_date = options[:start]
|
128
|
+
end_date = options[:end]
|
129
|
+
|
130
|
+
transactions = client.get_transactions(account_id, start_date)
|
131
|
+
|
132
|
+
# Filter by end date if specified
|
133
|
+
if end_date
|
134
|
+
end_date_obj = Date.parse(end_date)
|
135
|
+
transactions = transactions.select do |t|
|
136
|
+
transaction_date = t["postedAt"] ? Date.parse(t["postedAt"]) : Date.parse(t["createdAt"])
|
137
|
+
transaction_date <= end_date_obj
|
138
|
+
end
|
139
|
+
end
|
140
|
+
|
141
|
+
# Filter by status if specified
|
142
|
+
if options[:status]
|
143
|
+
transactions = transactions.select { |t| t["status"] == options[:status] }
|
144
|
+
end
|
145
|
+
|
146
|
+
# Filter by search term if specified
|
147
|
+
if options[:search]
|
148
|
+
search_term = options[:search].downcase
|
149
|
+
transactions = transactions.select do |t|
|
150
|
+
description = t["bankDescription"] || t["externalMemo"] || ""
|
151
|
+
description.downcase.include?(search_term)
|
152
|
+
end
|
153
|
+
end
|
154
|
+
|
155
|
+
if options[:json] || options[:format] == 'json'
|
156
|
+
# Format transactions for JSON output
|
157
|
+
formatted_transactions = transactions.map do |t|
|
158
|
+
{
|
159
|
+
'id' => t["id"],
|
160
|
+
'date' => t["postedAt"] || t["createdAt"],
|
161
|
+
'description' => t["bankDescription"] || t["externalMemo"] || "Unknown transaction",
|
162
|
+
'amount' => t["amount"],
|
163
|
+
'status' => t["status"],
|
164
|
+
'kind' => t["kind"],
|
165
|
+
'reconciled' => false,
|
166
|
+
'failed_at' => t["failedAt"],
|
167
|
+
'reason_for_failure' => t["reasonForFailure"]
|
168
|
+
}
|
169
|
+
end
|
170
|
+
|
171
|
+
puts JSON.pretty_generate({
|
172
|
+
'account_id' => account_id,
|
173
|
+
'account_name' => account['name'],
|
174
|
+
'account_number' => account['accountNumber'],
|
175
|
+
'total_transactions' => transactions.size,
|
176
|
+
'transactions' => formatted_transactions
|
177
|
+
})
|
178
|
+
else
|
179
|
+
# Display transactions in a table
|
180
|
+
puts "Transactions for #{account['name']} (#{account['accountNumber']})"
|
181
|
+
puts "Period: #{start_date} to #{end_date || 'present'}"
|
182
|
+
puts "Total transactions: #{transactions.size}"
|
183
|
+
puts
|
184
|
+
|
185
|
+
rows = transactions.map do |t|
|
186
|
+
date = t["postedAt"] ? Time.parse(t["postedAt"]).strftime("%Y-%m-%d") : Time.parse(t["createdAt"]).strftime("%Y-%m-%d")
|
187
|
+
description = t["bankDescription"] || t["externalMemo"] || "Unknown transaction"
|
188
|
+
description = description.length > 30 ? "#{description[0..27]}..." : description
|
189
|
+
amount = format("$%.2f", t["amount"])
|
190
|
+
status = t["status"]
|
191
|
+
kind = t["kind"]
|
192
|
+
reconciled = false
|
193
|
+
failure_reason = t["reasonForFailure"] || ""
|
194
|
+
|
195
|
+
[t["id"], date, description, amount, status, kind, reconciled, failure_reason]
|
196
|
+
end
|
197
|
+
|
198
|
+
table = ::Terminal::Table.new(
|
199
|
+
headings: ['Transaction ID', 'Date', 'Description', 'Amount', 'Status', 'Type', 'Reconciled', 'Failure Reason'],
|
200
|
+
rows: rows
|
201
|
+
)
|
202
|
+
|
203
|
+
puts table
|
204
|
+
end
|
205
|
+
end
|
206
|
+
end
|
207
|
+
end
|
208
|
+
end
|
209
|
+
end
|