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,306 @@
1
+ module MercuryBanking
2
+ module Formatters
3
+ # Formatter for exporting transactions to different formats
4
+ module ExportFormatter
5
+ # Export transactions to ledger format
6
+ def export_to_ledger(transactions, output_file, reconciled_transactions = [], verbose = false)
7
+ File.open(output_file, 'w') do |file|
8
+ file.puts "; Mercury Bank Transactions Export"
9
+ file.puts "; Generated on #{Time.now.strftime('%Y-%m-%d %H:%M:%S')}"
10
+ file.puts
11
+
12
+ # Add account declarations to ensure ledger recognizes them
13
+ accounts = transactions.map { |t| t["accountName"] || "Mercury Account" }.uniq
14
+ accounts.each do |account|
15
+ file.puts "account Assets:#{account}"
16
+ end
17
+ file.puts "account Expenses:Unknown"
18
+ file.puts "account Income:Unknown"
19
+ file.puts
20
+
21
+ # Debug: Show what accounts are being created
22
+ if verbose
23
+ puts "Creating ledger file with the following accounts:"
24
+ accounts.each do |account|
25
+ puts "- Assets:#{account}"
26
+ end
27
+ end
28
+
29
+ # Filter out failed transactions
30
+ valid_transactions = transactions.reject { |t| t["status"] == "failed" }
31
+
32
+ # Sort transactions chronologically by timestamp
33
+ sorted_transactions = sort_transactions_chronologically(valid_transactions)
34
+
35
+ sorted_transactions.each do |t|
36
+ # Get the full timestamp for precise ordering
37
+ timestamp = t["postedAt"] || t["createdAt"]
38
+ date = Time.parse(timestamp).strftime("%Y/%m/%d")
39
+ time = Time.parse(timestamp).strftime("%H:%M:%S")
40
+ description = t["bankDescription"] || t["externalMemo"] || "Unknown transaction"
41
+ amount = t["amount"].to_f
42
+ account_name = t["accountName"] || "Mercury Account"
43
+ transaction_id = t["id"]
44
+
45
+ # Check if this transaction is reconciled
46
+ is_reconciled = reconciled_transactions.include?(transaction_id)
47
+
48
+ # Sanitize description for ledger format - replace newlines with spaces and escape semicolons
49
+ description = description.gsub(/[\r\n]+/, ' ').gsub(/;/, '\\;')
50
+
51
+ # Ensure the description doesn't start with characters that could be interpreted as a date
52
+ # or other ledger syntax elements
53
+ if description =~ /^\d/ || description =~ /^[v#]/ || description =~ /^\s*\d{4}/ || description =~ /^\s*\d{2}\/\d{2}/
54
+ description = "- #{description}"
55
+ end
56
+
57
+ # Format the transaction with proper indentation and alignment
58
+ file.puts "#{date} #{is_reconciled ? '* ' : ''}#{description}"
59
+ file.puts " ; Transaction ID: #{transaction_id}"
60
+ file.puts " ; Timestamp: #{timestamp}"
61
+ file.puts " ; Time: #{time}"
62
+ file.puts " ; Status: #{t["status"]}"
63
+
64
+ # Add reconciliation metadata
65
+ if is_reconciled
66
+ file.puts " ; :reconciled: true"
67
+ end
68
+
69
+ # Add counterparty information if available
70
+ if t["counterpartyName"]
71
+ # Sanitize counterparty name
72
+ counterparty = t["counterpartyName"].gsub(/[\r\n]+/, ' ').gsub(/;/, '\\;')
73
+ file.puts " ; Counterparty: #{counterparty}"
74
+ end
75
+
76
+ # Add additional metadata if available
77
+ if t["externalMemo"] && t["externalMemo"] != description
78
+ # Sanitize memo
79
+ memo = t["externalMemo"].gsub(/[\r\n]+/, ' ').gsub(/;/, '\\;')
80
+ file.puts " ; Memo: #{memo}"
81
+ end
82
+
83
+ # Add the postings
84
+ if amount > 0
85
+ # Credit (money coming in)
86
+ file.puts " Assets:#{account_name} $#{format('%.2f', amount)}"
87
+ file.puts " Income:Unknown $#{format('%.2f', -amount)}"
88
+ else
89
+ # Debit (money going out)
90
+ file.puts " Assets:#{account_name} $#{format('%.2f', amount)}"
91
+ file.puts " Expenses:Unknown $#{format('%.2f', amount.abs)}"
92
+ end
93
+ file.puts
94
+ end
95
+ end
96
+
97
+ puts "Verifying ledger file validity..."
98
+ verify_ledger_file(output_file, verbose)
99
+ end
100
+
101
+ # Sort transactions chronologically by timestamp
102
+ def sort_transactions_chronologically(transactions)
103
+ transactions.sort_by do |t|
104
+ # Use postedAt if available, otherwise fall back to createdAt
105
+ timestamp = t["postedAt"] || t["createdAt"]
106
+ Time.parse(timestamp)
107
+ end
108
+ end
109
+
110
+ # Verify that the ledger file is valid
111
+ def verify_ledger_file(output_file, verbose = false)
112
+ begin
113
+ # Debug: Verify the ledger file is valid
114
+ cmd = "ledger -f #{output_file} balance"
115
+ output = `#{cmd}`
116
+
117
+ if $?.success?
118
+ if verbose
119
+ puts "Ledger verification output: #{output}"
120
+
121
+ # Show all accounts in the ledger file
122
+ cmd = "ledger -f #{output_file} accounts"
123
+ output = `#{cmd}`
124
+
125
+ if $?.success?
126
+ puts "All accounts in the ledger file:"
127
+ puts output
128
+ else
129
+ puts "Warning: Could not list accounts in the ledger file."
130
+ puts "This may indicate a problem with the file format, but the balance command succeeded."
131
+ end
132
+ 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
+ end
143
+ rescue => e
144
+ puts "Error during ledger verification: #{e.message}"
145
+ puts "This doesn't necessarily mean the file is invalid, but there may be issues with the ledger command."
146
+ end
147
+ end
148
+
149
+ # Export transactions to beancount format
150
+ def export_to_beancount(transactions, output_file, reconciled_transactions = [], verbose = false)
151
+ File.open(output_file, 'w') do |file|
152
+ file.puts "; Mercury Bank Transactions Export"
153
+ file.puts "; Generated on #{Time.now.strftime('%Y-%m-%d %H:%M:%S')}"
154
+ file.puts
155
+
156
+ # Filter out failed transactions
157
+ valid_transactions = transactions.reject { |t| t["status"] == "failed" }
158
+
159
+ # Get unique account names for debug output
160
+ if verbose
161
+ accounts = valid_transactions.map { |t| t["accountName"] || "Mercury" }.map { |name| name.gsub(/[^a-zA-Z0-9]/, '-') }.uniq
162
+ puts "Creating beancount file with the following accounts:"
163
+ accounts.each do |account|
164
+ puts "- Assets:#{account}"
165
+ end
166
+ end
167
+
168
+ valid_transactions.each do |t|
169
+ date = t["postedAt"] ? Time.parse(t["postedAt"]).strftime("%Y-%m-%d") : Time.parse(t["createdAt"]).strftime("%Y-%m-%d")
170
+ description = t["bankDescription"] || t["externalMemo"] || "Unknown transaction"
171
+ amount = t["amount"].to_f
172
+ account_name = t["accountName"] || "Mercury"
173
+ account_name = account_name.gsub(/[^a-zA-Z0-9]/, '-')
174
+ transaction_id = t["id"]
175
+
176
+ # Check if this transaction is reconciled
177
+ is_reconciled = reconciled_transactions.include?(transaction_id)
178
+ flag = is_reconciled ? "*" : "!"
179
+
180
+ # Sanitize description for beancount format
181
+ description = description.gsub(/[\r\n]+/, ' ').gsub(/"/, '\\"')
182
+
183
+ file.puts "#{date} #{flag} \"#{description}\""
184
+ file.puts " ; Transaction ID: #{transaction_id}"
185
+ file.puts " ; Status: #{t["status"]}"
186
+
187
+ # Add reconciliation metadata
188
+ if is_reconciled
189
+ file.puts " reconciled: \"true\""
190
+ file.puts " reconciled_at: \"#{Time.now.strftime('%Y-%m-%d')}\""
191
+ else
192
+ file.puts " reconciled: \"false\""
193
+ file.puts " reconciled_at: \"\""
194
+ end
195
+
196
+ if amount < 0
197
+ file.puts " Expenses:Unknown #{format("%.2f", amount.abs)} USD"
198
+ file.puts " Assets:#{account_name} #{format("%.2f", amount)} USD"
199
+ else
200
+ file.puts " Assets:#{account_name} #{format("%.2f", amount)} USD"
201
+ file.puts " Income:Unknown #{format("%.2f", -amount)} USD"
202
+ end
203
+ file.puts
204
+ end
205
+ end
206
+ end
207
+
208
+ # Export transactions to hledger format
209
+ def export_to_hledger(transactions, output_file, reconciled_transactions = [], verbose = false)
210
+ File.open(output_file, 'w') do |file|
211
+ file.puts "; Mercury Bank Transactions Export"
212
+ file.puts "; Generated on #{Time.now.strftime('%Y-%m-%d %H:%M:%S')}"
213
+ file.puts
214
+
215
+ # Filter out failed transactions
216
+ valid_transactions = transactions.reject { |t| t["status"] == "failed" }
217
+
218
+ # Get unique account names for debug output
219
+ if verbose
220
+ accounts = valid_transactions.map { |t| t["accountName"] || "Mercury Account" }.uniq
221
+ puts "Creating hledger file with the following accounts:"
222
+ accounts.each do |account|
223
+ puts "- Assets:#{account}"
224
+ end
225
+ end
226
+
227
+ valid_transactions.each do |t|
228
+ date = t["postedAt"] ? Time.parse(t["postedAt"]).strftime("%Y-%m-%d") : Time.parse(t["createdAt"]).strftime("%Y-%m-%d")
229
+ description = t["bankDescription"] || t["externalMemo"] || "Unknown transaction"
230
+ amount = t["amount"].to_f
231
+ account_name = t["accountName"] || "Mercury Account"
232
+ transaction_id = t["id"]
233
+
234
+ # Check if this transaction is reconciled
235
+ is_reconciled = reconciled_transactions.include?(transaction_id)
236
+ status_marker = is_reconciled ? " * " : " ! "
237
+
238
+ # Sanitize description for hledger format
239
+ description = description.gsub(/[\r\n]+/, ' ').gsub(/;/, '\\;')
240
+
241
+ file.puts "#{date}#{status_marker}#{description}"
242
+ file.puts " ; Transaction ID: #{transaction_id}"
243
+ file.puts " ; Status: #{t["status"]}"
244
+
245
+ # Add reconciliation metadata
246
+ if is_reconciled
247
+ file.puts " reconciled: true"
248
+ file.puts " reconciled-at: #{Time.now.strftime('%Y-%m-%d')}"
249
+ else
250
+ file.puts " reconciled: false"
251
+ file.puts " reconciled-at:"
252
+ end
253
+
254
+ if amount < 0
255
+ file.puts " Expenses:Unknown $#{format("%.2f", amount.abs)}"
256
+ file.puts " Assets:#{account_name} $#{format("%.2f", amount)}"
257
+ else
258
+ file.puts " Assets:#{account_name} $#{format("%.2f", amount)}"
259
+ file.puts " Income:Unknown $#{format("%.2f", -amount)}"
260
+ end
261
+ file.puts
262
+ end
263
+ end
264
+ end
265
+
266
+ # Export transactions to CSV format
267
+ def export_to_csv(transactions, output_file, reconciled_transactions = [], verbose = false)
268
+ require 'csv'
269
+
270
+ CSV.open(output_file, 'w') do |csv|
271
+ # Write header
272
+ csv << ['Date', 'Description', 'Amount', 'Account', 'Transaction ID', 'Status', 'Reconciled', 'Reconciled At']
273
+
274
+ # Filter out failed transactions
275
+ valid_transactions = transactions.reject { |t| t["status"] == "failed" }
276
+
277
+ if verbose
278
+ puts "Creating CSV file with #{valid_transactions.size} transactions"
279
+ puts "CSV columns: Date, Description, Amount, Account, Transaction ID, Status, Reconciled, Reconciled At"
280
+ end
281
+
282
+ # Write transactions
283
+ valid_transactions.each do |t|
284
+ date = t["postedAt"] ? Time.parse(t["postedAt"]).strftime("%Y-%m-%d") : Time.parse(t["createdAt"]).strftime("%Y-%m-%d")
285
+ description = t["bankDescription"] || t["externalMemo"] || "Unknown transaction"
286
+
287
+ # Replace newlines with spaces for CSV format
288
+ description = description.gsub(/[\r\n]+/, ' ')
289
+
290
+ amount = t["amount"].to_f
291
+ account_name = t["accountName"] || "Mercury Account"
292
+ transaction_id = t["id"]
293
+ status = t["status"]
294
+
295
+ # Check if this transaction is reconciled
296
+ is_reconciled = reconciled_transactions.include?(transaction_id)
297
+ reconciled = is_reconciled ? "Yes" : "No"
298
+ reconciled_at = is_reconciled ? Time.now.strftime("%Y-%m-%d") : ""
299
+
300
+ csv << [date, description, amount, account_name, transaction_id, status, reconciled, reconciled_at]
301
+ end
302
+ end
303
+ end
304
+ end
305
+ end
306
+ end
@@ -0,0 +1,133 @@
1
+ module MercuryBanking
2
+ module Formatters
3
+ # Formatter for table output
4
+ module TableFormatter
5
+ # Display accounts in a table format
6
+ def display_accounts_table(accounts)
7
+ return puts "No accounts found." if accounts.empty?
8
+
9
+ rows = accounts.map do |a|
10
+ [
11
+ a["name"],
12
+ a["accountNumber"],
13
+ a["kind"].capitalize,
14
+ format("$%.2f", a["currentBalance"]),
15
+ format("$%.2f", a["availableBalance"]),
16
+ a["status"].capitalize
17
+ ]
18
+ end
19
+
20
+ table = ::Terminal::Table.new(
21
+ headings: ['Account Name', 'Account Number', 'Type', 'Current Balance', 'Available Balance', 'Status'],
22
+ rows: rows
23
+ )
24
+
25
+ puts table
26
+ end
27
+
28
+ # Display recipients in a table format
29
+ def display_recipients_table(recipients)
30
+ return puts "No recipients found." if recipients.empty?
31
+
32
+ rows = recipients.map do |r|
33
+ account_info = r["electronicRoutingInfo"] || {}
34
+ address_info = r["address"] || {}
35
+
36
+ [
37
+ r["id"],
38
+ r["name"],
39
+ account_info["routingNumber"] || "N/A",
40
+ mask_account_number(account_info["accountNumber"]),
41
+ address_info["city"] || "N/A",
42
+ r["status"].capitalize
43
+ ]
44
+ end
45
+
46
+ table = ::Terminal::Table.new(
47
+ headings: ['ID', 'Name', 'Routing #', 'Account #', 'City', 'Status'],
48
+ rows: rows
49
+ )
50
+
51
+ puts table
52
+ end
53
+
54
+ # Display transactions in a table format
55
+ def display_transactions_table(transactions, show_account_name = false)
56
+ return puts "No transactions found." if transactions.empty?
57
+
58
+ headings = ['Date', 'Amount', 'Type']
59
+ headings.insert(0, 'Account') if show_account_name
60
+ headings.concat(['Description', 'Status'])
61
+
62
+ rows = transactions.map do |t|
63
+ date = t["postedAt"] ? Time.parse(t["postedAt"]).strftime("%Y-%m-%d") : "Pending"
64
+ amount = format("$%.2f", t["amount"].abs)
65
+ direction = t["amount"] < 0 ? "DEBIT" : "CREDIT"
66
+ description = t["bankDescription"] || t["externalMemo"] || "N/A"
67
+ description = description.length > 50 ? "#{description[0..47]}..." : description
68
+ status = t["status"].capitalize
69
+
70
+ row = [date, amount, direction, description, status]
71
+ row.insert(0, t["accountName"]) if show_account_name
72
+ row
73
+ end
74
+
75
+ table = ::Terminal::Table.new(
76
+ headings: headings,
77
+ rows: rows
78
+ )
79
+
80
+ puts table
81
+ end
82
+
83
+ # Display a single transaction's details
84
+ def display_transaction_details(transaction)
85
+ return puts "Transaction not found." unless transaction
86
+
87
+ # Format dates
88
+ created_at = transaction["createdAt"] ? Time.parse(transaction["createdAt"]).strftime("%Y-%m-%d %H:%M:%S") : "N/A"
89
+ posted_at = transaction["postedAt"] ? Time.parse(transaction["postedAt"]).strftime("%Y-%m-%d %H:%M:%S") : "Pending"
90
+ estimated_delivery = transaction["estimatedDeliveryDate"] ? Time.parse(transaction["estimatedDeliveryDate"]).strftime("%Y-%m-%d") : "N/A"
91
+
92
+ # Format amount
93
+ amount = transaction["amount"].to_f
94
+ amount_str = format("$%.2f", amount.abs)
95
+ direction = amount < 0 ? "DEBIT" : "CREDIT"
96
+
97
+ # Get counterparty info
98
+ counterparty = transaction["counterpartyId"] ? "ID: #{transaction["counterpartyId"]}" : "N/A"
99
+
100
+ # Create a table for the transaction details
101
+ details = [
102
+ ["Transaction ID", transaction["id"]],
103
+ ["Amount", "#{amount_str} (#{direction})"],
104
+ ["Status", transaction["status"].capitalize],
105
+ ["Created At", created_at],
106
+ ["Posted At", posted_at],
107
+ ["Estimated Delivery", estimated_delivery],
108
+ ["Description", transaction["bankDescription"] || "N/A"],
109
+ ["Note", transaction["note"] || "N/A"],
110
+ ["External Memo", transaction["externalMemo"] || "N/A"],
111
+ ["Counterparty", counterparty]
112
+ ]
113
+
114
+ table = ::Terminal::Table.new(
115
+ title: "Transaction Details",
116
+ rows: details
117
+ )
118
+
119
+ puts table
120
+ end
121
+
122
+ private
123
+
124
+ # Mask account number for security
125
+ def mask_account_number(account_number)
126
+ return "N/A" unless account_number
127
+
128
+ # Show only the last 4 digits
129
+ "••••#{account_number[-4..-1]}" if account_number.length >= 4
130
+ end
131
+ end
132
+ end
133
+ end
@@ -0,0 +1,135 @@
1
+ module MercuryBanking
2
+ class Multi
3
+ def initialize(keys = [])
4
+ @banks = {}
5
+ @apis = {}
6
+ @keys = keys
7
+
8
+ @keys.each do |api_key|
9
+ next if api_key.empty?
10
+ mercury = MercuryBanking::API.new(api_key)
11
+
12
+ begin
13
+ accounts = mercury.accounts
14
+ rescue StandardError => e
15
+ puts e
16
+ next
17
+ end
18
+
19
+ if accounts.nil?
20
+ puts "*" * 80
21
+ puts "* #{api_key} returned no accounts"
22
+ puts "*" * 80
23
+ next
24
+ end
25
+
26
+ checking = find_all_checking_accounts(accounts)
27
+
28
+ checking.each do |ac|
29
+ identifier = ac['name']
30
+ @banks[identifier] = ac
31
+ end
32
+
33
+ @identifier = accounts.first["name"]
34
+ count_checking_accounts(accounts)
35
+ @apis[@identifier] = mercury
36
+ end
37
+ end
38
+
39
+ def count_checking_accounts(accounts)
40
+ checking_count = accounts.count { |a| a["kind"] == "checking" }
41
+ puts "More than one checking account for #{@identifier}: #{checking_count}" if checking_count > 1
42
+ end
43
+
44
+ def find_all_checking_accounts(accounts)
45
+ accounts.find_all { |a| a["kind"] == "checking" && a["status"] == "active" }
46
+ end
47
+
48
+ def balances
49
+ table = Terminal::Table.new rows: @banks.collect { |name, vals|
50
+ [name, vals["availableBalance"], vals["status"]]
51
+ }
52
+ print table.to_s + "\n"
53
+ end
54
+
55
+ def ensure_recipient(from:, to:, email:, address:, city:, region:, postal_code:, country:)
56
+ raise "Target bank account not found: #{to}" unless @banks[to]
57
+ mercury_source = @apis[from]
58
+ target = mercury_source.find_recipient(name: @banks[to]["name"])
59
+
60
+ if target.nil?
61
+ target = add_target_account_to_recipient_list(mercury_source, to, email, address, city, region, postal_code, country)
62
+ end
63
+ target
64
+ end
65
+
66
+ def add_target_account_to_recipient_list(mercury_source, to, email, address, city, region, postal_code, country)
67
+ puts "Adding recipient: #{@banks[to]["name"]}"
68
+ rec = MercuryBanking::Recipient.new(
69
+ name: @banks[to]["name"],
70
+ address: address,
71
+ email: email,
72
+ account_number: @banks[to]["accountNumber"],
73
+ routing_number: @banks[to]["routingNumber"],
74
+ city: city,
75
+ region: region,
76
+ postal_code: postal_code,
77
+ country: country
78
+ )
79
+ mercury_source.post(rec.json, "recipients")
80
+ mercury_source.find_recipient(name: @banks[to]["name"])
81
+ end
82
+
83
+ def ensure_account(account)
84
+ found = @banks.keys.grep(/#{account}/i)
85
+ raise "Account: #{account} not found." if found.empty?
86
+ raise "Account: #{account} matched multiple #{found.join(",")}." if found.size > 1
87
+ found.first
88
+ end
89
+
90
+ def transfer(from:, to:, amount:, note:, wait: false, email:, address:, city:, region:, postal_code:, country:)
91
+ puts "#{from} -> #{to} (#{amount})"
92
+ from_account = ensure_account(from)
93
+ to_account = ensure_account(to)
94
+
95
+ # Create a recipient with "to" information in "from"'s account:
96
+ recipient = ensure_recipient(
97
+ from: from_account,
98
+ to: to_account,
99
+ email: email,
100
+ address: address,
101
+ city: city,
102
+ region: region,
103
+ postal_code: postal_code,
104
+ country: country
105
+ )
106
+
107
+ puts "From account #{@banks[from_account]["id"]} -> Recipient #{recipient["id"]} (account: #{@banks[to_account]["id"]})"
108
+ transfer = @apis[from_account].transfer(
109
+ recipient_id: recipient["id"],
110
+ amount: amount,
111
+ account_id: @banks[from_account]["id"],
112
+ note: note,
113
+ external: "Ex #{note}"
114
+ )
115
+
116
+ if wait
117
+ while transfer["status"] != "sent"
118
+ puts "Waiting on #{transfer["id"]} (#{transfer["status"]}) to complete."
119
+ sleep 10
120
+ transfer = @apis[from_account].transaction(@banks[from_account]["id"], transfer["id"])
121
+ if transfer["status"] == "failed"
122
+ puts transfer
123
+ end
124
+ end
125
+ end
126
+
127
+ write_log(from_account, to_account, amount, note, transfer)
128
+ transfer
129
+ end
130
+
131
+ def write_log(from, to, amount, note, transfer)
132
+ File.write("log", "\"#{from}\", \"#{to}\", \"#{amount}\", \"#{note}\", \"#{transfer["status"]}\"\n")
133
+ end
134
+ end
135
+ end
@@ -0,0 +1,29 @@
1
+ module MercuryBanking
2
+ class Recipient
3
+ attr_accessor :name
4
+
5
+ def initialize(name:, email:, account_number:, routing_number:, address:, city:, region:, postal_code:, country:)
6
+ @name = name
7
+ @address = {
8
+ name: name,
9
+ address1: address,
10
+ city: city,
11
+ region: region,
12
+ postalCode: postal_code,
13
+ country: country
14
+ }
15
+ @emails = [ email ]
16
+ @paymentMethod = "electronic"
17
+ @electronicRoutingInfo = {
18
+ accountNumber: account_number,
19
+ routingNumber: routing_number,
20
+ electronicAccountType: "businessChecking",
21
+ address: @address
22
+ }
23
+ end
24
+
25
+ def json
26
+ { name: @name, address: @address, emails: @emails, paymentMethod: @paymentMethod, electronicRoutingInfo: @electronicRoutingInfo }
27
+ end
28
+ end
29
+ end