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,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