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.
data/README.md ADDED
@@ -0,0 +1,244 @@
1
+ # MercuryBanking
2
+
3
+ Client library for talking to [Mercury API](https://docs.mercury.com/reference).
4
+
5
+ This gem comes in handy when you want to access all of your bank accounts and their transactions histories or make payments to existing recipients in your Mercury account.
6
+
7
+ ## Installation
8
+
9
+ Add this line to your application's Gemfile:
10
+
11
+ ```ruby
12
+ gem 'mercury_banking'
13
+ ```
14
+
15
+ And then execute:
16
+
17
+ $ bundle install
18
+
19
+ Or install it yourself as:
20
+
21
+ $ gem install mercury_banking
22
+
23
+ ## Usage
24
+
25
+ First initialize client:
26
+
27
+ ```ruby
28
+ require 'mercury_banking'
29
+
30
+ # For single account
31
+ client = Mercury::API.new("YOUR_API_KEY")
32
+
33
+ # For multiple accounts
34
+ multi_client = Mercury::Multi.new(['YOUR_API_KEY'])
35
+ ```
36
+
37
+ Then call the API
38
+
39
+ ```ruby
40
+ client.accounts
41
+
42
+ multi_client.balances
43
+ ```
44
+
45
+ ### CLI Usage
46
+
47
+ The gem also provides a command-line interface for interacting with Mercury Banking:
48
+
49
+ ```bash
50
+ # Display all accounts
51
+ mercury accounts
52
+
53
+ # List transactions for an account
54
+ mercury transactions ACCOUNT_ID_OR_NUMBER
55
+
56
+ # Download all transactions as CSV files
57
+ mercury transactions_download
58
+
59
+ # Download transactions with custom options
60
+ mercury transactions_download --output_dir=my_transactions --start_date=2023-01-01 --end_date=2023-12-31
61
+
62
+ # Download transactions in beancount format
63
+ mercury transactions_download --format=beancount
64
+
65
+ # Download transactions in both CSV and beancount formats
66
+ mercury transactions_download --format=both
67
+
68
+ # Generate a balance sheet report
69
+ mercury financials balancesheet
70
+
71
+ # Generate a balance sheet report with custom options
72
+ mercury financials balancesheet --format=beancount --start=2023-01-01 --end=2023-12-31 --verbose
73
+
74
+ # Generate an income statement report
75
+ mercury financials incomestatement
76
+ ```
77
+
78
+ The `transactions_download` command will download all Mercury transactions in the format:
79
+ ```
80
+ transactions/YEAR-MO-Mercury-ACCOUNT-ID.csv
81
+ ```
82
+
83
+ For example: `transactions/2023-01-Mercury-123456789.csv`
84
+
85
+ When using the `--format=beancount` option, it will create files in the format:
86
+ ```
87
+ transactions/YEAR-MO-Mercury-ACCOUNT-ID.beancount
88
+ ```
89
+
90
+ ### API Usage
91
+
92
+ For transferring money to a _registered_ recipient:
93
+
94
+ ```ruby
95
+ client.transfer(
96
+ recipient_id: RECIPIENT_ID,
97
+ amount: 100000, # amount should be in USD
98
+ account_id: ACCOUNT_ID,
99
+ note: "TEST"
100
+ )
101
+ ```
102
+
103
+ json response:
104
+
105
+ ```ruby
106
+ {
107
+ "amount": 1000000,
108
+ "bankDescription": null,
109
+ "counterpartyId": "dd486506-d66b-11e9-8d8a-0b2e6a550f30",
110
+ "counterpartyName": "Fake P. Erson",
111
+ "counterpartyNickname": null,
112
+ "createdAt": "2019-09-13T21:17:15.974788Z",
113
+ "dashboardLink": "https://mercury.com/transactions/dd4896c0-d66b-11e9-8d8a-cf31cc71dda0",
114
+ "details": {
115
+ "address": null,
116
+ "domesticWireRoutingInfo": null,
117
+ "electronicRoutingInfo": null,
118
+ "internationalWireRoutingInfo": null
119
+ },
120
+ "estimatedDeliveryDate": "2019-09-13T21:17:17.387585Z",
121
+ "failedAt": null,
122
+ "id": "dd4896c0-d66b-11e9-8d8a-cf31cc71dda0",
123
+ "kind": "externalTransfer",
124
+ "note": null,
125
+ "externalMemo": null,
126
+ "postedAt": "2019-09-13T21:17:17.387585Z",
127
+ "reasonForFailure": null,
128
+ "status": "sent",
129
+ "feeId": null
130
+ },
131
+ ```
132
+
133
+ For transferring between two Mercury accounts:
134
+
135
+ ```ruby
136
+ multi_client.transfer(
137
+ from: 'ACCOUNT_NAME_1',
138
+ to: 'ACCOUNT_NAME_2',
139
+ amount: 100000, # amount should be in USD
140
+ note: 'test',
141
+ email: 'test@gmail.com',
142
+ address: 'test address',
143
+ city: 'Minato',
144
+ region: 'Tokyo', # Either a two-letter US state code like "CA" for California or a free-form identification of a particular region worldwide
145
+ postal_code: '1110000',
146
+ country: 'JP' # ISO3166Alpha2 Ex. US, JP, etc.
147
+ )
148
+ ```
149
+
150
+ json_response:
151
+
152
+ ```ruby
153
+ {
154
+ "amount": 1000000,
155
+ "bankDescription": null,
156
+ "counterpartyId": "dd486506-d66b-11e9-8d8a-0b2e6a550f30",
157
+ "counterpartyName": "Fake P. Erson",
158
+ "counterpartyNickname": null,
159
+ "createdAt": "2019-09-13T21:17:15.974788Z",
160
+ "dashboardLink": "https://mercury.com/transactions/dd4896c0-d66b-11e9-8d8a-cf31cc71dda0",
161
+ "details": {
162
+ "address": null,
163
+ "domesticWireRoutingInfo": null,
164
+ "electronicRoutingInfo": null,
165
+ "internationalWireRoutingInfo": null
166
+ },
167
+ "estimatedDeliveryDate": "2019-09-13T21:17:17.387585Z",
168
+ "failedAt": null,
169
+ "id": "dd4896c0-d66b-11e9-8d8a-cf31cc71dda0",
170
+ "kind": "externalTransfer",
171
+ "note": null,
172
+ "externalMemo": null,
173
+ "postedAt": "2019-09-13T21:17:17.387585Z",
174
+ "reasonForFailure": null,
175
+ "status": "sent",
176
+ "feeId": null
177
+ },
178
+ ```
179
+
180
+ ## Development
181
+
182
+ After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests.
183
+
184
+ To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and tags, and push the `.gem` file to [rubygems.org](https://rubygems.org).
185
+
186
+ ## Test
187
+
188
+ We use [RSpec](http://rspec.info/) for testing. To run test use the following command:
189
+
190
+ ```ruby
191
+ rspec spec
192
+ ```
193
+
194
+ or
195
+
196
+ ```ruby
197
+ rake spec
198
+ ```
199
+
200
+ ### Testing
201
+
202
+ For running the test suite:
203
+
204
+ 1. Run `bundle install`
205
+ 2. Run `bundle exec rspec` to run the automated tests
206
+
207
+ For manual testing:
208
+
209
+ 1. Run `bundle install`
210
+ 2. Rename `.env_test` to `.env`
211
+ 3. Inside the `.env` file, add your Mercury API key
212
+ 4. Use the CLI commands to interact with the Mercury API
213
+
214
+ ## ToDo
215
+
216
+ - To cover all endpoints
217
+ - Throw useful errors
218
+ - Encryption
219
+
220
+ ## Contributing
221
+
222
+ 1. Fork it
223
+ 2. Create your feature branch (`git checkout -b my-new-feature`).
224
+ 3. Commit your changes (`git commit -am "Add new feature"`).
225
+ 4. Push to the branch (`git push origin my-new-feature`).
226
+ 5. Create a new Pull Request.
227
+
228
+ Bug reports and pull requests are welcome on GitHub at https://github.com/XenonIO/mercury-banking. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [code of conduct](https://github.com/XenonIO/mercury-banking/blob/master/CODE_OF_CONDUCT.md).
229
+
230
+ ## Release Notes
231
+
232
+ - v1.0.0: Initial major release.
233
+
234
+ ## Reference
235
+
236
+ See [Mercury docs](https://docs.mercury.com/reference) for the complete list of endpoints.
237
+
238
+ ## License
239
+
240
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
241
+
242
+ ## Code of Conduct
243
+
244
+ Everyone interacting in the MercuryBanking project's codebases, issue trackers, chat rooms and mailing lists is expected to follow the [code of conduct](https://github.com/XenonIO/mercury-banking/blob/master/CODE_OF_CONDUCT.md).
data/Rakefile ADDED
@@ -0,0 +1,6 @@
1
+ require "bundler/gem_tasks"
2
+ require "rspec/core/rake_task"
3
+
4
+ RSpec::Core::RakeTask.new(:spec)
5
+
6
+ task :default => :spec
data/bin/console ADDED
@@ -0,0 +1,14 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require "bundler/setup"
4
+ require "mercury_banking"
5
+
6
+ # You can add fixtures and/or initialization code here to make experimenting
7
+ # with your gem easier. You can also use a different console, if you like.
8
+
9
+ # (If you use this, don't forget to add pry to your Gemfile!)
10
+ # require "pry"
11
+ # Pry.start
12
+
13
+ require "irb"
14
+ IRB.start(__FILE__)
data/bin/mercury ADDED
@@ -0,0 +1,17 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require 'thor'
4
+ require 'mercury_banking'
5
+ require 'dotenv/load'
6
+ require 'json'
7
+ require 'fileutils'
8
+ require 'base64'
9
+ require 'terminal-table'
10
+ require 'time'
11
+ require 'logger' # Explicitly require logger
12
+ require 'securerandom'
13
+ require 'mercury_banking/cli'
14
+ require 'symmetric-encryption'
15
+
16
+ # Start the CLI
17
+ MercuryBanking::CLI::Main.start(ARGV)
data/bin/setup ADDED
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+ IFS=$'\n\t'
4
+ set -vx
5
+
6
+ bundle install
7
+
8
+ # Do any other automated setup that you need to do here
@@ -0,0 +1,184 @@
1
+ module MercuryBanking
2
+ class API
3
+ def initialize(secret)
4
+ @api_key = secret
5
+ end
6
+
7
+ def get(path)
8
+ check_if_the_path_is_valid(path)
9
+ response = send_get_request_to_api(path)
10
+ body = parse_json(response)
11
+ validate_body(body, path)
12
+ body
13
+ end
14
+
15
+ def check_if_the_path_is_valid(path)
16
+ if path.nil? || path.empty?
17
+ raise 'Path cannot be empty'
18
+ elsif path[0] == '/'
19
+ raise "Path should not start with a /."
20
+ else
21
+ path
22
+ end
23
+ end
24
+
25
+ def send_get_request_to_api(path)
26
+ base_url = "https://backend.mercury.com/api/v1/"
27
+ url = URI.join(base_url, path)
28
+
29
+ http = Net::HTTP.new(url.host, url.port)
30
+ http.use_ssl = true
31
+
32
+ request = Net::HTTP::Get.new(url)
33
+ request.basic_auth @api_key, ''
34
+
35
+ http.request(request)
36
+ end
37
+
38
+ def parse_json(response)
39
+ JSON.parse(response.read_body)
40
+ end
41
+
42
+ def validate_body(body, path)
43
+ if body["errors"]
44
+ raise "#{@api_key} access to #{path} errored with #{body["errors"]["message"]}"
45
+ end
46
+
47
+ body
48
+ end
49
+
50
+ def post(object, path)
51
+ check_if_the_path_is_valid(path)
52
+ response = send_post_request_to_api(object, path)
53
+ body = parse_json(response)
54
+ validate_body(body, path)
55
+ body
56
+ end
57
+
58
+ def send_post_request_to_api(object, path)
59
+ base_url = "https://backend.mercury.com/api/v1/"
60
+ url = URI.join(base_url, path)
61
+
62
+ http = Net::HTTP.new(url.host, url.port)
63
+ http.use_ssl = true
64
+
65
+ request = Net::HTTP::Post.new(url)
66
+ request["accept"] = 'application/json'
67
+ request["content-type"] = 'application/json'
68
+ request.body = object.to_json
69
+
70
+ request.basic_auth @api_key, ''
71
+
72
+ http.request(request)
73
+ end
74
+
75
+ # Userland API
76
+ def accounts
77
+ get("accounts")["accounts"]
78
+ end
79
+
80
+ def get_account(account_id)
81
+ path = "account/#{account_id}"
82
+ get(path)
83
+ end
84
+
85
+ # Find account by account number
86
+ def find_account_by_number(account_number)
87
+ account = accounts.find { |a| a["accountNumber"] == account_number.to_s }
88
+ raise "Account with number #{account_number} not found" unless account
89
+ account
90
+ end
91
+
92
+ def balance(account_id)
93
+ account = get_account(account_id)
94
+ account['currentBalance']
95
+ end
96
+
97
+ # /account/:id/transactions
98
+ def get_transactions(account_id, start_date = nil)
99
+ path = "account/#{account_id}/transactions"
100
+ path += "?start=#{start_date}" if start_date
101
+ get(path)["transactions"]
102
+ end
103
+
104
+ # Get transactions from all accounts
105
+ def get_all_transactions(start_date = nil)
106
+ all_transactions = []
107
+
108
+ accounts.each do |account|
109
+ begin
110
+ account_transactions = get_transactions(account["id"], start_date)
111
+ # Add account information to each transaction
112
+ account_transactions.each do |transaction|
113
+ transaction["accountName"] = account["name"]
114
+ transaction["accountId"] = account["id"]
115
+ end
116
+ all_transactions.concat(account_transactions)
117
+ rescue StandardError => e
118
+ puts "Warning: Could not fetch transactions for account #{account["name"]}: #{e.message}" unless ENV['MERCURY_SILENT']
119
+ end
120
+ end
121
+
122
+ # Sort all transactions by date (newest first)
123
+ all_transactions.sort_by { |t| t["createdAt"] || "" }.reverse
124
+ end
125
+
126
+ # /account/:id/transactions/:id
127
+ def transaction(account_id, transaction_id)
128
+ path = "account/#{account_id}/transaction/#{transaction_id}"
129
+ get(path)
130
+ end
131
+
132
+ def recipients
133
+ get("recipients")["recipients"]
134
+ end
135
+
136
+ def find_recipient(name:)
137
+ recipients.find { |r| r["name"] == name && r['status'] != 'deleted' }
138
+ end
139
+
140
+ def get_recipient(recipient_id)
141
+ path = "recipient/#{recipient_id}"
142
+ get(path)
143
+ end
144
+
145
+ def add_recipient(name:, address:, email:, city:, region:, postal_code:, country:, account_number:, routing_number:)
146
+ rec = MercuryBanking::Recipient.new(
147
+ name: name,
148
+ address: address,
149
+ email: email,
150
+ city: city,
151
+ region: region,
152
+ postal_code: postal_code,
153
+ country: country,
154
+ account_number: account_number,
155
+ routing_number: routing_number
156
+ )
157
+ post(rec.json, 'recipients')
158
+ new_recipient = find_recipient(name: name)
159
+ raise "Couldn't add account #{name}" unless new_recipient
160
+ puts "Successfully added #{new_recipient['name']}" if new_recipient
161
+ end
162
+
163
+ def update_recipient(name:, address:, email:, city:, region:, postal_code:, country:, account_number:, routing_number:, recipient_id:)
164
+ rec = MercuryBanking::Recipient.new(
165
+ name: name,
166
+ address: address,
167
+ email: email,
168
+ city: city,
169
+ region: region,
170
+ postal_code: postal_code,
171
+ country: country,
172
+ account_number: account_number,
173
+ routing_number: routing_number
174
+ )
175
+ post(rec.json, "recipient/#{recipient_id}")
176
+ end
177
+
178
+ def transfer(recipient_id:, amount:, account_id:, note: nil, external: nil)
179
+ payload = { recipientId: recipient_id, amount: amount, paymentMethod: 'ach',
180
+ note: note, externalMemo: external, idempotencyKey: "#{recipient_id}-#{amount}-#{note}-#{external}" }
181
+ self.post(payload, "account/#{account_id}/transactions")
182
+ end
183
+ end
184
+ end
@@ -0,0 +1,61 @@
1
+ module MercuryBanking
2
+ module CLI
3
+ # Module for account-related commands
4
+ module Accounts
5
+ # Add account-related commands to the CLI class
6
+ def self.included(base)
7
+ base.class_eval do
8
+ desc 'accounts', 'Display all accounts'
9
+ method_option :format, type: :string, default: 'table', enum: ['table', 'json'], desc: 'Output format (table or json)'
10
+ def accounts
11
+ with_api_client do |client|
12
+ accounts = client.accounts
13
+
14
+ if options[:json] || options[:format] == 'json'
15
+ puts JSON.pretty_generate(accounts)
16
+ else
17
+ display_accounts_table(accounts)
18
+ end
19
+
20
+ puts "You have #{accounts.count} account/s" unless options[:json]
21
+ end
22
+ end
23
+
24
+ desc 'balance ACCOUNT_ID_OR_NUMBER', "Display the current balance of an account (using account number from accounts table)"
25
+ def balance(account_identifier)
26
+ with_api_client do |client|
27
+ # Determine if we're dealing with an account ID or account number
28
+ account_id = nil
29
+ if account_identifier.match?(/^\d+$/) && !account_identifier.include?('-')
30
+ begin
31
+ account = client.find_account_by_number(account_identifier)
32
+ account_id = account["id"]
33
+ rescue => e
34
+ # If not found by number, assume it's an ID
35
+ account_id = account_identifier
36
+ account = client.get_account(account_id)
37
+ end
38
+ else
39
+ account_id = account_identifier
40
+ account = client.get_account(account_id)
41
+ end
42
+
43
+ balance = client.balance(account_id)
44
+
45
+ if options[:json]
46
+ puts JSON.pretty_generate({
47
+ 'account_id' => account_id,
48
+ 'account_number' => account['accountNumber'],
49
+ 'name' => account['name'],
50
+ 'balance' => balance
51
+ })
52
+ else
53
+ puts "#{account['name']} (#{account['accountNumber']}): $#{format("%.2f", balance)}"
54
+ end
55
+ end
56
+ end
57
+ end
58
+ end
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,68 @@
1
+ module MercuryBanking
2
+ module CLI
3
+ # Base module for CLI functionality
4
+ module Base
5
+ # Get API client with error handling
6
+ def with_api_client
7
+ api_key = get_api_key
8
+ raise "API key not found. Please run 'mercury set_key' first." unless api_key
9
+
10
+ client = MercuryBanking::API.new(api_key)
11
+ yield client
12
+ rescue StandardError => e
13
+ if options[:json]
14
+ puts JSON.pretty_generate({ 'error' => e.message })
15
+ else
16
+ puts "Error: #{e.message}"
17
+ end
18
+ exit 1
19
+ end
20
+
21
+ # Get the API key from the encrypted storage
22
+ def get_api_key
23
+ config_path = File.join(Dir.home, '.mercury-banking', 'api_key.enc')
24
+ key_path = File.join(Dir.home, '.mercury-banking', 'key_config.json')
25
+
26
+ unless File.exist?(config_path) && File.exist?(key_path)
27
+ raise "API key not found. Please run 'mercury set_key' first."
28
+ end
29
+
30
+ begin
31
+ key_config = JSON.parse(File.read(key_path))
32
+
33
+ # Initialize the SymmetricEncryption with the loaded cipher
34
+ cipher = SymmetricEncryption::Cipher.new(
35
+ key: Base64.strict_decode64(key_config['key']),
36
+ iv: Base64.strict_decode64(key_config['iv']),
37
+ cipher_name: key_config['cipher_name'] || 'aes-256-cbc'
38
+ )
39
+
40
+ # Set the cipher as the primary one
41
+ SymmetricEncryption.cipher = cipher
42
+
43
+ encrypted_key = File.read(config_path)
44
+ cipher.decrypt(encrypted_key)
45
+ rescue => e
46
+ puts "Error decrypting API key: #{e.message}"
47
+ exit 1
48
+ end
49
+ end
50
+
51
+ # Log transfer details
52
+ def log_transfer(from, to, amount, note, status)
53
+ log_dir = File.join(Dir.home, '.mercury-banking', 'logs')
54
+ FileUtils.mkdir_p(log_dir)
55
+
56
+ log_file = File.join(log_dir, 'transfers.log')
57
+ File.open(log_file, 'a') do |f|
58
+ f.puts "#{Time.now.iso8601},\"#{from}\",\"#{to}\",\"#{amount}\",\"#{note}\",\"#{status}\""
59
+ end
60
+ end
61
+
62
+ # Check if a command exists in the system
63
+ def command_exists?(command)
64
+ system("which #{command} > /dev/null 2>&1")
65
+ end
66
+ end
67
+ end
68
+ end