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,406 @@
|
|
1
|
+
module MercuryBanking
|
2
|
+
module CLI
|
3
|
+
# Module for reconciliation-related commands
|
4
|
+
module Reconciliation
|
5
|
+
# Add reconciliation-related commands to the CLI class
|
6
|
+
def self.included(base)
|
7
|
+
base.class_eval do
|
8
|
+
desc 'reconcile ACCOUNT_ID_OR_NUMBER TRANSACTION_ID', 'Mark a transaction as reconciled'
|
9
|
+
def reconcile(account_identifier, transaction_id)
|
10
|
+
with_api_client do |client|
|
11
|
+
# Determine if we're dealing with an account ID or account number
|
12
|
+
account_id = nil
|
13
|
+
if account_identifier.match?(/^\d+$/) && !account_identifier.include?('-')
|
14
|
+
begin
|
15
|
+
account = client.find_account_by_number(account_identifier)
|
16
|
+
account_id = account["id"]
|
17
|
+
rescue => e
|
18
|
+
# If not found by number, assume it's an ID
|
19
|
+
account_id = account_identifier
|
20
|
+
account = client.get_account(account_id)
|
21
|
+
end
|
22
|
+
else
|
23
|
+
account_id = account_identifier
|
24
|
+
account = client.get_account(account_id)
|
25
|
+
end
|
26
|
+
|
27
|
+
# Get the transaction to verify it exists
|
28
|
+
transaction = client.transaction(account_id, transaction_id)
|
29
|
+
|
30
|
+
# Mark the transaction as reconciled
|
31
|
+
reconciliation = MercuryBanking::Reconciliation.new
|
32
|
+
result = reconciliation.mark_reconciled(account_id, transaction_id)
|
33
|
+
|
34
|
+
if options[:json]
|
35
|
+
puts JSON.pretty_generate({
|
36
|
+
'account_id' => account_id,
|
37
|
+
'transaction_id' => transaction_id,
|
38
|
+
'reconciled' => true,
|
39
|
+
'already_reconciled' => !result
|
40
|
+
})
|
41
|
+
else
|
42
|
+
if result
|
43
|
+
puts "Transaction #{transaction_id} marked as reconciled."
|
44
|
+
else
|
45
|
+
puts "Transaction #{transaction_id} was already reconciled."
|
46
|
+
end
|
47
|
+
end
|
48
|
+
end
|
49
|
+
end
|
50
|
+
|
51
|
+
desc 'unreconcile ACCOUNT_ID_OR_NUMBER TRANSACTION_ID', 'Mark a transaction as unreconciled'
|
52
|
+
def unreconcile(account_identifier, transaction_id)
|
53
|
+
with_api_client do |client|
|
54
|
+
# Determine if we're dealing with an account ID or account number
|
55
|
+
account_id = nil
|
56
|
+
if account_identifier.match?(/^\d+$/) && !account_identifier.include?('-')
|
57
|
+
begin
|
58
|
+
account = client.find_account_by_number(account_identifier)
|
59
|
+
account_id = account["id"]
|
60
|
+
rescue => e
|
61
|
+
# If not found by number, assume it's an ID
|
62
|
+
account_id = account_identifier
|
63
|
+
account = client.get_account(account_id)
|
64
|
+
end
|
65
|
+
else
|
66
|
+
account_id = account_identifier
|
67
|
+
account = client.get_account(account_id)
|
68
|
+
end
|
69
|
+
|
70
|
+
# Get the transaction to verify it exists
|
71
|
+
transaction = client.transaction(account_id, transaction_id)
|
72
|
+
|
73
|
+
# Mark the transaction as unreconciled
|
74
|
+
reconciliation = MercuryBanking::Reconciliation.new
|
75
|
+
result = reconciliation.mark_unreconciled(account_id, transaction_id)
|
76
|
+
|
77
|
+
if options[:json]
|
78
|
+
puts JSON.pretty_generate({
|
79
|
+
'account_id' => account_id,
|
80
|
+
'transaction_id' => transaction_id,
|
81
|
+
'reconciled' => false,
|
82
|
+
'was_reconciled' => result
|
83
|
+
})
|
84
|
+
else
|
85
|
+
if result
|
86
|
+
puts "Transaction #{transaction_id} marked as unreconciled."
|
87
|
+
else
|
88
|
+
puts "Transaction #{transaction_id} was not reconciled."
|
89
|
+
end
|
90
|
+
end
|
91
|
+
end
|
92
|
+
end
|
93
|
+
|
94
|
+
desc 'reconciliation_status ACCOUNT_ID_OR_NUMBER', 'Show reconciliation status for an account'
|
95
|
+
method_option :start, type: :string, default: '2020-01-01', desc: 'Start date for transactions (YYYY-MM-DD)'
|
96
|
+
method_option :end, type: :string, desc: 'End date for transactions (YYYY-MM-DD)'
|
97
|
+
method_option :format, type: :string, default: 'table', enum: ['table', 'json'], desc: 'Output format (table or json)'
|
98
|
+
def reconciliation_status(account_identifier)
|
99
|
+
with_api_client do |client|
|
100
|
+
# Determine if we're dealing with an account ID or account number
|
101
|
+
account_id = nil
|
102
|
+
if account_identifier.match?(/^\d+$/) && !account_identifier.include?('-')
|
103
|
+
begin
|
104
|
+
account = client.find_account_by_number(account_identifier)
|
105
|
+
account_id = account["id"]
|
106
|
+
rescue => e
|
107
|
+
# If not found by number, assume it's an ID
|
108
|
+
account_id = account_identifier
|
109
|
+
account = client.get_account(account_id)
|
110
|
+
end
|
111
|
+
else
|
112
|
+
account_id = account_identifier
|
113
|
+
account = client.get_account(account_id)
|
114
|
+
end
|
115
|
+
|
116
|
+
# Get transactions for the account
|
117
|
+
start_date = options[:start]
|
118
|
+
end_date = options[:end]
|
119
|
+
|
120
|
+
transactions = client.get_transactions(account_id, start_date)
|
121
|
+
|
122
|
+
# Filter by end date if specified
|
123
|
+
if end_date
|
124
|
+
end_date_obj = Date.parse(end_date)
|
125
|
+
transactions = transactions.select do |t|
|
126
|
+
transaction_date = t["postedAt"] ? Date.parse(t["postedAt"]) : Date.parse(t["createdAt"])
|
127
|
+
transaction_date <= end_date_obj
|
128
|
+
end
|
129
|
+
end
|
130
|
+
|
131
|
+
# Get reconciliation status
|
132
|
+
reconciliation = MercuryBanking::Reconciliation.new
|
133
|
+
status = reconciliation.get_reconciliation_status(account_id, transactions)
|
134
|
+
summary = reconciliation.get_reconciliation_summary(account_id, transactions)
|
135
|
+
|
136
|
+
if options[:json] || options[:format] == 'json'
|
137
|
+
puts JSON.pretty_generate({
|
138
|
+
'account_id' => account_id,
|
139
|
+
'account_name' => account['name'],
|
140
|
+
'summary' => summary,
|
141
|
+
'transactions' => status
|
142
|
+
})
|
143
|
+
else
|
144
|
+
# Display summary
|
145
|
+
puts "Reconciliation Status for #{account['name']} (#{account['accountNumber']})"
|
146
|
+
puts "Period: #{start_date} to #{end_date || 'present'}"
|
147
|
+
puts
|
148
|
+
puts "Summary:"
|
149
|
+
puts "- Total Transactions: #{summary[:total_transactions]}"
|
150
|
+
puts "- Reconciled: #{summary[:reconciled_count]} ($#{format("%.2f", summary[:reconciled_amount])})"
|
151
|
+
puts "- Unreconciled: #{summary[:unreconciled_count]} ($#{format("%.2f", summary[:unreconciled_amount])})"
|
152
|
+
puts
|
153
|
+
|
154
|
+
# Display transactions
|
155
|
+
rows = status.map do |t|
|
156
|
+
date = t[:date] ? Time.parse(t[:date]).strftime("%Y-%m-%d") : "Unknown"
|
157
|
+
description = t[:description].length > 30 ? "#{t[:description][0..27]}..." : t[:description]
|
158
|
+
amount = format("$%.2f", t[:amount])
|
159
|
+
reconciled = t[:reconciled] ? "✓" : " "
|
160
|
+
reconciled_at = t[:reconciled_at] || ""
|
161
|
+
|
162
|
+
[t[:transaction_id], date, description, amount, reconciled, reconciled_at]
|
163
|
+
end
|
164
|
+
|
165
|
+
table = ::Terminal::Table.new(
|
166
|
+
headings: ['Transaction ID', 'Date', 'Description', 'Amount', 'Reconciled', 'Reconciled At'],
|
167
|
+
rows: rows
|
168
|
+
)
|
169
|
+
|
170
|
+
puts table
|
171
|
+
end
|
172
|
+
end
|
173
|
+
end
|
174
|
+
|
175
|
+
desc 'reconcile_all ACCOUNT_ID_OR_NUMBER', 'Mark all transactions as reconciled up to a specified date'
|
176
|
+
method_option :date, type: :string, desc: 'Date up to which to reconcile transactions (YYYY-MM-DD). Defaults to current date.'
|
177
|
+
method_option :start, type: :string, default: '2020-01-01', desc: 'Start date for transactions (YYYY-MM-DD)'
|
178
|
+
method_option :dry_run, type: :boolean, default: false, desc: 'Show what would be reconciled without making changes'
|
179
|
+
method_option :json, type: :boolean, default: false, desc: 'Output in JSON format'
|
180
|
+
method_option :debug, type: :boolean, default: false, desc: 'Show debug information'
|
181
|
+
def reconcile_all(account_identifier)
|
182
|
+
with_api_client do |client|
|
183
|
+
# Determine if we're dealing with an account ID or account number
|
184
|
+
account_id = nil
|
185
|
+
if account_identifier.match?(/^\d+$/) && !account_identifier.include?('-')
|
186
|
+
begin
|
187
|
+
account = client.find_account_by_number(account_identifier)
|
188
|
+
account_id = account["id"]
|
189
|
+
rescue => e
|
190
|
+
# If not found by number, assume it's an ID
|
191
|
+
account_id = account_identifier
|
192
|
+
account = client.get_account(account_id)
|
193
|
+
end
|
194
|
+
else
|
195
|
+
account_id = account_identifier
|
196
|
+
account = client.get_account(account_id)
|
197
|
+
end
|
198
|
+
|
199
|
+
# Get transactions for the account
|
200
|
+
start_date = options[:start]
|
201
|
+
puts "Fetching transactions for account #{account['name']} (#{account['accountNumber']}) since #{start_date}..." if options[:debug]
|
202
|
+
transactions = client.get_transactions(account_id, start_date)
|
203
|
+
puts "Found #{transactions.size} total transactions" if options[:debug]
|
204
|
+
|
205
|
+
# Filter by date if specified
|
206
|
+
cutoff_date = options[:date] ? Date.parse(options[:date]) : Date.today
|
207
|
+
puts "Using cutoff date: #{cutoff_date}" if options[:debug]
|
208
|
+
|
209
|
+
# Filter transactions that are on or before the cutoff date
|
210
|
+
transactions_to_reconcile = transactions.select do |t|
|
211
|
+
transaction_date = t["postedAt"] ? Date.parse(t["postedAt"]) : Date.parse(t["createdAt"])
|
212
|
+
transaction_date <= cutoff_date
|
213
|
+
end
|
214
|
+
puts "Found #{transactions_to_reconcile.size} transactions on or before cutoff date" if options[:debug]
|
215
|
+
|
216
|
+
if transactions_to_reconcile.empty?
|
217
|
+
if options[:json]
|
218
|
+
puts JSON.pretty_generate({
|
219
|
+
'account_id' => account_id,
|
220
|
+
'account_name' => account['name'],
|
221
|
+
'transactions_reconciled' => 0,
|
222
|
+
'message' => "No transactions found to reconcile."
|
223
|
+
})
|
224
|
+
else
|
225
|
+
puts "No transactions found to reconcile for #{account['name']} (#{account['accountNumber']})."
|
226
|
+
end
|
227
|
+
return
|
228
|
+
end
|
229
|
+
|
230
|
+
# Get already reconciled transactions
|
231
|
+
reconciliation = MercuryBanking::Reconciliation.new
|
232
|
+
reconciled_ids = reconciliation.get_reconciled_transactions(account_id)
|
233
|
+
puts "Found #{reconciled_ids.size} already reconciled transactions" if options[:debug]
|
234
|
+
|
235
|
+
# Filter out already reconciled transactions
|
236
|
+
transactions_to_reconcile = transactions_to_reconcile.reject { |t| reconciled_ids.include?(t["id"]) }
|
237
|
+
puts "Found #{transactions_to_reconcile.size} transactions to reconcile" if options[:debug]
|
238
|
+
|
239
|
+
if transactions_to_reconcile.empty?
|
240
|
+
if options[:json]
|
241
|
+
puts JSON.pretty_generate({
|
242
|
+
'account_id' => account_id,
|
243
|
+
'account_name' => account['name'],
|
244
|
+
'transactions_reconciled' => 0,
|
245
|
+
'message' => "All transactions are already reconciled up to #{cutoff_date}."
|
246
|
+
})
|
247
|
+
else
|
248
|
+
puts "All transactions are already reconciled up to #{cutoff_date} for #{account['name']} (#{account['accountNumber']})."
|
249
|
+
end
|
250
|
+
return
|
251
|
+
end
|
252
|
+
|
253
|
+
# Display what will be reconciled
|
254
|
+
if options[:json]
|
255
|
+
transactions_data = transactions_to_reconcile.map do |t|
|
256
|
+
{
|
257
|
+
'id' => t["id"],
|
258
|
+
'date' => t["postedAt"] || t["createdAt"],
|
259
|
+
'description' => t["bankDescription"] || t["externalMemo"] || "Unknown transaction",
|
260
|
+
'amount' => t["amount"]
|
261
|
+
}
|
262
|
+
end
|
263
|
+
|
264
|
+
puts JSON.pretty_generate({
|
265
|
+
'account_id' => account_id,
|
266
|
+
'account_name' => account['name'],
|
267
|
+
'cutoff_date' => cutoff_date.to_s,
|
268
|
+
'transactions_to_reconcile' => transactions_data,
|
269
|
+
'dry_run' => options[:dry_run]
|
270
|
+
})
|
271
|
+
else
|
272
|
+
puts "Reconciliation for #{account['name']} (#{account['accountNumber']})"
|
273
|
+
puts "Transactions to reconcile up to #{cutoff_date}:"
|
274
|
+
puts
|
275
|
+
|
276
|
+
total_amount = 0
|
277
|
+
|
278
|
+
rows = transactions_to_reconcile.map do |t|
|
279
|
+
date = t["postedAt"] ? Time.parse(t["postedAt"]).strftime("%Y-%m-%d") : Time.parse(t["createdAt"]).strftime("%Y-%m-%d")
|
280
|
+
description = t["bankDescription"] || t["externalMemo"] || "Unknown transaction"
|
281
|
+
description = description.length > 30 ? "#{description[0..27]}..." : description
|
282
|
+
amount = t["amount"]
|
283
|
+
total_amount += amount
|
284
|
+
|
285
|
+
[t["id"], date, description, format("$%.2f", amount)]
|
286
|
+
end
|
287
|
+
|
288
|
+
table = ::Terminal::Table.new(
|
289
|
+
headings: ['Transaction ID', 'Date', 'Description', 'Amount'],
|
290
|
+
rows: rows
|
291
|
+
)
|
292
|
+
|
293
|
+
puts table
|
294
|
+
puts
|
295
|
+
puts "Total transactions: #{transactions_to_reconcile.size}"
|
296
|
+
puts "Total amount: #{format("$%.2f", total_amount)}"
|
297
|
+
puts
|
298
|
+
puts "This is a #{options[:dry_run] ? 'DRY RUN' : 'LIVE RUN'}"
|
299
|
+
end
|
300
|
+
|
301
|
+
# Mark transactions as reconciled if not a dry run
|
302
|
+
unless options[:dry_run]
|
303
|
+
reconciled_count = 0
|
304
|
+
|
305
|
+
transactions_to_reconcile.each do |t|
|
306
|
+
result = reconciliation.mark_reconciled(account_id, t["id"])
|
307
|
+
reconciled_count += 1 if result
|
308
|
+
end
|
309
|
+
|
310
|
+
if options[:json]
|
311
|
+
puts JSON.pretty_generate({
|
312
|
+
'account_id' => account_id,
|
313
|
+
'account_name' => account['name'],
|
314
|
+
'transactions_reconciled' => reconciled_count,
|
315
|
+
'message' => "Successfully reconciled #{reconciled_count} transactions."
|
316
|
+
})
|
317
|
+
else
|
318
|
+
puts "Successfully reconciled #{reconciled_count} transactions."
|
319
|
+
end
|
320
|
+
else
|
321
|
+
if !options[:json]
|
322
|
+
puts "Dry run completed. No transactions were reconciled."
|
323
|
+
puts "Run without --dry-run to reconcile these transactions."
|
324
|
+
end
|
325
|
+
end
|
326
|
+
end
|
327
|
+
end
|
328
|
+
|
329
|
+
desc 'export_reconciled ACCOUNT_ID_OR_NUMBER', 'Export reconciled transactions to a ledger file'
|
330
|
+
method_option :start, type: :string, default: '2020-01-01', desc: 'Start date for transactions (YYYY-MM-DD)'
|
331
|
+
method_option :end, type: :string, desc: 'End date for transactions (YYYY-MM-DD)'
|
332
|
+
method_option :format, type: :string, default: 'ledger', enum: ['ledger', 'beancount', 'hledger', 'csv'], desc: 'Export format'
|
333
|
+
method_option :output, type: :string, desc: 'Output file path (defaults to reconciled_transactions.<format>)'
|
334
|
+
method_option :all, type: :boolean, default: false, desc: 'Export all transactions with reconciliation status'
|
335
|
+
def export_reconciled(account_identifier)
|
336
|
+
with_api_client do |client|
|
337
|
+
# Determine if we're dealing with an account ID or account number
|
338
|
+
account_id = nil
|
339
|
+
if account_identifier.match?(/^\d+$/) && !account_identifier.include?('-')
|
340
|
+
begin
|
341
|
+
account = client.find_account_by_number(account_identifier)
|
342
|
+
account_id = account["id"]
|
343
|
+
rescue => e
|
344
|
+
# If not found by number, assume it's an ID
|
345
|
+
account_id = account_identifier
|
346
|
+
account = client.get_account(account_id)
|
347
|
+
end
|
348
|
+
else
|
349
|
+
account_id = account_identifier
|
350
|
+
account = client.get_account(account_id)
|
351
|
+
end
|
352
|
+
|
353
|
+
# Get transactions for the account
|
354
|
+
start_date = options[:start]
|
355
|
+
end_date = options[:end]
|
356
|
+
format = options[:format]
|
357
|
+
export_all = options[:all]
|
358
|
+
|
359
|
+
transactions = client.get_transactions(account_id, start_date)
|
360
|
+
|
361
|
+
# Filter by end date if specified
|
362
|
+
if end_date
|
363
|
+
end_date_obj = Date.parse(end_date)
|
364
|
+
transactions = transactions.select do |t|
|
365
|
+
transaction_date = t["postedAt"] ? Date.parse(t["postedAt"]) : Date.parse(t["createdAt"])
|
366
|
+
transaction_date <= end_date_obj
|
367
|
+
end
|
368
|
+
end
|
369
|
+
|
370
|
+
# Get reconciled transactions
|
371
|
+
reconciliation = MercuryBanking::Reconciliation.new
|
372
|
+
reconciled_ids = reconciliation.get_reconciled_transactions(account_id)
|
373
|
+
|
374
|
+
# Filter transactions if not exporting all
|
375
|
+
unless export_all
|
376
|
+
transactions = transactions.select { |t| reconciled_ids.include?(t["id"]) }
|
377
|
+
end
|
378
|
+
|
379
|
+
if transactions.empty?
|
380
|
+
puts "No #{export_all ? '' : 'reconciled '}transactions found to export."
|
381
|
+
return
|
382
|
+
end
|
383
|
+
|
384
|
+
# Determine output file path
|
385
|
+
output_file = options[:output] || "reconciled_transactions.#{format}"
|
386
|
+
|
387
|
+
# Export transactions in the specified format
|
388
|
+
case format
|
389
|
+
when 'ledger'
|
390
|
+
export_to_ledger(transactions, output_file, reconciled_ids, false)
|
391
|
+
when 'beancount'
|
392
|
+
export_to_beancount(transactions, output_file, reconciled_ids, false)
|
393
|
+
when 'hledger'
|
394
|
+
export_to_hledger(transactions, output_file, reconciled_ids)
|
395
|
+
when 'csv'
|
396
|
+
export_to_csv(transactions, output_file, reconciled_ids)
|
397
|
+
end
|
398
|
+
|
399
|
+
puts "Exported #{transactions.count} transaction(s) to #{output_file} in #{format} format."
|
400
|
+
end
|
401
|
+
end
|
402
|
+
end
|
403
|
+
end
|
404
|
+
end
|
405
|
+
end
|
406
|
+
end
|
@@ -0,0 +1,265 @@
|
|
1
|
+
module MercuryBanking
|
2
|
+
module CLI
|
3
|
+
# Module for report-related commands
|
4
|
+
module Reports
|
5
|
+
# Add report-related commands to the CLI class
|
6
|
+
def self.included(base)
|
7
|
+
base.class_eval do
|
8
|
+
# Methods that should not be exposed as commands
|
9
|
+
no_commands do
|
10
|
+
# The balance_sheet method has been moved to the Financials module
|
11
|
+
end
|
12
|
+
|
13
|
+
desc 'statement ACCOUNT_NAME', 'Generate a statement for a specific account and period'
|
14
|
+
method_option :start, type: :string, default: '2020-01-01', desc: 'Start date for statement (YYYY-MM-DD)'
|
15
|
+
method_option :end, type: :string, desc: 'End date for statement (YYYY-MM-DD)'
|
16
|
+
method_option :format, type: :string, default: 'text', enum: ['text', 'json', 'csv'], desc: 'Output format'
|
17
|
+
method_option :save, type: :string, desc: 'Save statement to specified file'
|
18
|
+
method_option :debug, type: :boolean, default: false, desc: 'Show debug information about balance calculation'
|
19
|
+
def statement(account_name)
|
20
|
+
with_api_client do |client|
|
21
|
+
start_date = options[:start]
|
22
|
+
end_date = options[:end]
|
23
|
+
format = options[:format]
|
24
|
+
debug = options[:debug]
|
25
|
+
|
26
|
+
# Find the account by name
|
27
|
+
accounts = client.accounts
|
28
|
+
account = accounts.find { |a| a["name"] == account_name }
|
29
|
+
|
30
|
+
unless account
|
31
|
+
puts "Error: Account '#{account_name}' not found. Available accounts:"
|
32
|
+
accounts.each { |a| puts "- #{a['name']}" }
|
33
|
+
return
|
34
|
+
end
|
35
|
+
|
36
|
+
# Get transactions for the account
|
37
|
+
transactions = client.get_transactions(account["id"], start_date)
|
38
|
+
|
39
|
+
# Filter by end date if specified
|
40
|
+
if end_date
|
41
|
+
end_date_obj = Date.parse(end_date)
|
42
|
+
transactions = transactions.select do |t|
|
43
|
+
transaction_date = t["postedAt"] ? Date.parse(t["postedAt"]) : Date.parse(t["createdAt"])
|
44
|
+
transaction_date <= end_date_obj
|
45
|
+
end
|
46
|
+
end
|
47
|
+
|
48
|
+
if transactions.empty?
|
49
|
+
puts "No transactions found for the specified period."
|
50
|
+
return
|
51
|
+
end
|
52
|
+
|
53
|
+
# Sort transactions by date
|
54
|
+
transactions = transactions.sort_by do |t|
|
55
|
+
date = t["postedAt"] || t["createdAt"]
|
56
|
+
Time.parse(date)
|
57
|
+
end
|
58
|
+
|
59
|
+
# Calculate opening and closing balances
|
60
|
+
current_balance = account["currentBalance"]
|
61
|
+
closing_balance = current_balance
|
62
|
+
|
63
|
+
if debug
|
64
|
+
puts "Debug: Current balance from Mercury API: $#{format("%.2f", current_balance)}"
|
65
|
+
end
|
66
|
+
|
67
|
+
# Subtract all transactions that occurred after the end date
|
68
|
+
if end_date
|
69
|
+
end_date_obj = Date.parse(end_date)
|
70
|
+
after_end_date_transactions = transactions.select do |t|
|
71
|
+
transaction_date = t["postedAt"] ? Date.parse(t["postedAt"]) : Date.parse(t["createdAt"])
|
72
|
+
transaction_date > end_date_obj
|
73
|
+
end
|
74
|
+
|
75
|
+
if debug && after_end_date_transactions.any?
|
76
|
+
puts "Debug: Transactions after end date (#{end_date}):"
|
77
|
+
after_end_date_transactions.each do |t|
|
78
|
+
date = t["postedAt"] ? Time.parse(t["postedAt"]).strftime("%Y-%m-%d") : Time.parse(t["createdAt"]).strftime("%Y-%m-%d")
|
79
|
+
puts " #{date}: $#{format("%.2f", t["amount"])}"
|
80
|
+
end
|
81
|
+
end
|
82
|
+
|
83
|
+
after_end_date_sum = after_end_date_transactions.sum { |t| t["amount"] }
|
84
|
+
closing_balance = current_balance - after_end_date_sum
|
85
|
+
|
86
|
+
if debug
|
87
|
+
puts "Debug: Sum of transactions after end date: $#{format("%.2f", after_end_date_sum)}"
|
88
|
+
puts "Debug: Closing balance at end date: $#{format("%.2f", closing_balance)}"
|
89
|
+
end
|
90
|
+
else
|
91
|
+
if debug
|
92
|
+
puts "Debug: No end date specified, closing balance equals current balance"
|
93
|
+
end
|
94
|
+
end
|
95
|
+
|
96
|
+
# Calculate opening balance by subtracting all transactions in the period
|
97
|
+
opening_balance = closing_balance
|
98
|
+
|
99
|
+
if debug
|
100
|
+
puts "Debug: Transactions in the statement period:"
|
101
|
+
end
|
102
|
+
|
103
|
+
transactions_sum = 0
|
104
|
+
transactions.each do |t|
|
105
|
+
amount = t["amount"]
|
106
|
+
transactions_sum += amount
|
107
|
+
|
108
|
+
if debug
|
109
|
+
date = t["postedAt"] ? Time.parse(t["postedAt"]).strftime("%Y-%m-%d") : Time.parse(t["createdAt"]).strftime("%Y-%m-%d")
|
110
|
+
puts " #{date}: $#{format("%.2f", amount)}"
|
111
|
+
end
|
112
|
+
|
113
|
+
opening_balance -= amount
|
114
|
+
end
|
115
|
+
|
116
|
+
if debug
|
117
|
+
puts "Debug: Sum of transactions in period: $#{format("%.2f", transactions_sum)}"
|
118
|
+
puts "Debug: Opening balance calculation: $#{format("%.2f", closing_balance)} - $#{format("%.2f", transactions_sum)} = $#{format("%.2f", opening_balance)}"
|
119
|
+
end
|
120
|
+
|
121
|
+
# Generate statement based on format
|
122
|
+
case format
|
123
|
+
when 'text'
|
124
|
+
generate_text_statement(account, transactions, opening_balance, closing_balance, start_date, end_date, options[:save], debug)
|
125
|
+
when 'json'
|
126
|
+
generate_json_statement(account, transactions, opening_balance, closing_balance, start_date, end_date, options[:save])
|
127
|
+
when 'csv'
|
128
|
+
generate_csv_statement(account, transactions, opening_balance, closing_balance, start_date, end_date, options[:save])
|
129
|
+
end
|
130
|
+
end
|
131
|
+
end
|
132
|
+
|
133
|
+
private
|
134
|
+
|
135
|
+
def generate_text_statement(account, transactions, opening_balance, closing_balance, start_date, end_date, save_path, debug = false)
|
136
|
+
# Create statement header
|
137
|
+
statement = []
|
138
|
+
statement << "Mercury Banking Statement"
|
139
|
+
statement << "Account: #{account['name']} (#{account['accountNumber']})"
|
140
|
+
statement << "Period: #{start_date} to #{end_date || 'present'}"
|
141
|
+
statement << "Generated on: #{Time.now.strftime('%Y-%m-%d %H:%M:%S')}"
|
142
|
+
statement << ""
|
143
|
+
statement << "Opening Balance: $#{format("%.2f", opening_balance)}"
|
144
|
+
statement << ""
|
145
|
+
statement << "Transactions:"
|
146
|
+
statement << "-" * 80
|
147
|
+
statement << "Date".ljust(12) + "Description".ljust(40) + "Amount".ljust(15) + "Balance"
|
148
|
+
statement << "-" * 80
|
149
|
+
|
150
|
+
# Add transactions
|
151
|
+
running_balance = opening_balance
|
152
|
+
transactions.each do |t|
|
153
|
+
date = t["postedAt"] ? Time.parse(t["postedAt"]).strftime("%Y-%m-%d") : Time.parse(t["createdAt"]).strftime("%Y-%m-%d")
|
154
|
+
description = t["bankDescription"] || t["externalMemo"] || "Unknown transaction"
|
155
|
+
description = description.length > 37 ? "#{description[0..34]}..." : description
|
156
|
+
amount = t["amount"]
|
157
|
+
running_balance += amount
|
158
|
+
|
159
|
+
statement << date.ljust(12) + description.ljust(40) + format("$%.2f", amount).rjust(15) + format("$%.2f", running_balance).rjust(15)
|
160
|
+
end
|
161
|
+
|
162
|
+
statement << "-" * 80
|
163
|
+
statement << "Closing Balance: $#{format("%.2f", closing_balance)}"
|
164
|
+
|
165
|
+
if debug
|
166
|
+
statement << ""
|
167
|
+
statement << "Debug Information:"
|
168
|
+
statement << "- Opening balance is calculated by taking the closing balance and subtracting all transactions in the period"
|
169
|
+
statement << "- Closing balance is the current balance adjusted for any transactions after the end date"
|
170
|
+
statement << "- Running balance starts with the opening balance and adds each transaction amount"
|
171
|
+
end
|
172
|
+
|
173
|
+
# Output or save
|
174
|
+
if save_path
|
175
|
+
File.write(save_path, statement.join("\n"))
|
176
|
+
puts "Statement saved to #{save_path}"
|
177
|
+
else
|
178
|
+
puts statement.join("\n")
|
179
|
+
end
|
180
|
+
end
|
181
|
+
|
182
|
+
def generate_json_statement(account, transactions, opening_balance, closing_balance, start_date, end_date, save_path)
|
183
|
+
# Format transactions
|
184
|
+
formatted_transactions = transactions.map do |t|
|
185
|
+
{
|
186
|
+
date: t["postedAt"] || t["createdAt"],
|
187
|
+
description: t["bankDescription"] || t["externalMemo"] || "Unknown transaction",
|
188
|
+
amount: t["amount"],
|
189
|
+
status: t["status"]
|
190
|
+
}
|
191
|
+
end
|
192
|
+
|
193
|
+
# Create statement object
|
194
|
+
statement = {
|
195
|
+
account: {
|
196
|
+
name: account['name'],
|
197
|
+
number: account['accountNumber'],
|
198
|
+
type: account['kind']
|
199
|
+
},
|
200
|
+
period: {
|
201
|
+
start_date: start_date,
|
202
|
+
end_date: end_date
|
203
|
+
},
|
204
|
+
balances: {
|
205
|
+
opening: opening_balance,
|
206
|
+
closing: closing_balance
|
207
|
+
},
|
208
|
+
transactions: formatted_transactions,
|
209
|
+
generated_at: Time.now.iso8601
|
210
|
+
}
|
211
|
+
|
212
|
+
# Output or save
|
213
|
+
json_output = JSON.pretty_generate(statement)
|
214
|
+
|
215
|
+
if save_path
|
216
|
+
File.write(save_path, json_output)
|
217
|
+
puts "Statement saved to #{save_path}"
|
218
|
+
else
|
219
|
+
puts json_output
|
220
|
+
end
|
221
|
+
end
|
222
|
+
|
223
|
+
def generate_csv_statement(account, transactions, opening_balance, closing_balance, start_date, end_date, save_path)
|
224
|
+
require 'csv'
|
225
|
+
|
226
|
+
# Prepare CSV data
|
227
|
+
csv_data = []
|
228
|
+
|
229
|
+
# Add header row
|
230
|
+
csv_data << ["Date", "Description", "Amount", "Balance", "Status"]
|
231
|
+
|
232
|
+
# Add opening balance row
|
233
|
+
csv_data << [start_date, "Opening Balance", "", opening_balance, ""]
|
234
|
+
|
235
|
+
# Add transactions
|
236
|
+
running_balance = opening_balance
|
237
|
+
transactions.each do |t|
|
238
|
+
date = t["postedAt"] ? Time.parse(t["postedAt"]).strftime("%Y-%m-%d") : Time.parse(t["createdAt"]).strftime("%Y-%m-%d")
|
239
|
+
description = t["bankDescription"] || t["externalMemo"] || "Unknown transaction"
|
240
|
+
amount = t["amount"]
|
241
|
+
running_balance += amount
|
242
|
+
|
243
|
+
csv_data << [date, description, amount, running_balance, t["status"]]
|
244
|
+
end
|
245
|
+
|
246
|
+
# Add closing balance row
|
247
|
+
csv_data << [end_date || Time.now.strftime("%Y-%m-%d"), "Closing Balance", "", closing_balance, ""]
|
248
|
+
|
249
|
+
# Output or save
|
250
|
+
csv_output = CSV.generate do |csv|
|
251
|
+
csv_data.each { |row| csv << row }
|
252
|
+
end
|
253
|
+
|
254
|
+
if save_path
|
255
|
+
File.write(save_path, csv_output)
|
256
|
+
puts "Statement saved to #{save_path}"
|
257
|
+
else
|
258
|
+
puts csv_output
|
259
|
+
end
|
260
|
+
end
|
261
|
+
end
|
262
|
+
end
|
263
|
+
end
|
264
|
+
end
|
265
|
+
end
|