teri 0.5.1
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/LICENSE.txt +25 -0
- data/README.md +285 -0
- data/bin/console +14 -0
- data/bin/setup +8 -0
- data/bin/teri +5 -0
- data/lib/teri/accounting.rb +310 -0
- data/lib/teri/ai_integration.rb +109 -0
- data/lib/teri/category_manager.rb +92 -0
- data/lib/teri/cli.rb +71 -0
- data/lib/teri/ledger.rb +332 -0
- data/lib/teri/openai_client.rb +229 -0
- data/lib/teri/report_generator.rb +258 -0
- data/lib/teri/transaction.rb +399 -0
- data/lib/teri/transaction_coder.rb +338 -0
- data/lib/teri/version.rb +3 -0
- data/lib/teri.rb +10 -0
- metadata +105 -0
@@ -0,0 +1,229 @@
|
|
1
|
+
require 'openai'
|
2
|
+
require 'logger'
|
3
|
+
require 'fileutils'
|
4
|
+
|
5
|
+
module Teri
|
6
|
+
class OpenAIClient
|
7
|
+
attr_reader :client, :logger, :model
|
8
|
+
|
9
|
+
def initialize(options = {})
|
10
|
+
@api_key = options[:api_key] || ENV.fetch('OPENAI_API_KEY', nil)
|
11
|
+
unless @api_key
|
12
|
+
raise 'OpenAI API key not found. Please set OPENAI_API_KEY environment variable or provide it as an argument.'
|
13
|
+
end
|
14
|
+
|
15
|
+
@model = options[:model] || 'gpt-3.5-turbo'
|
16
|
+
@client = OpenAI::Client.new(access_token: @api_key)
|
17
|
+
|
18
|
+
# Set up logging
|
19
|
+
setup_logger(options[:log_file])
|
20
|
+
end
|
21
|
+
|
22
|
+
# Get a suggested category for a transaction based on its details and previous codings
|
23
|
+
# @param transaction [Teri::Transaction] The transaction to get a suggestion for
|
24
|
+
# @param accounting [Teri::Accounting] The accounting instance with previous codings
|
25
|
+
# @return [Hash] A hash containing the suggested category and confidence score
|
26
|
+
def suggest_category(transaction, accounting)
|
27
|
+
previous_codings = accounting.respond_to?(:previous_codings) ? accounting.previous_codings : {}
|
28
|
+
counterparty_hints = accounting.respond_to?(:counterparty_hints) ? accounting.counterparty_hints : {}
|
29
|
+
available_categories = []
|
30
|
+
|
31
|
+
# Prepare the prompt with transaction details
|
32
|
+
prompt = build_prompt(transaction, accounting)
|
33
|
+
|
34
|
+
# Log the previous_codings for debugging
|
35
|
+
if @logger
|
36
|
+
@logger.info("PREVIOUS_CODINGS: #{previous_codings.inspect}")
|
37
|
+
@logger.info("COUNTERPARTY_HINTS: #{counterparty_hints.inspect}")
|
38
|
+
end
|
39
|
+
|
40
|
+
# Log the prompt
|
41
|
+
@logger&.info("PROMPT: #{prompt}")
|
42
|
+
|
43
|
+
# Call the OpenAI API
|
44
|
+
response = @client.chat(
|
45
|
+
parameters: {
|
46
|
+
model: @model,
|
47
|
+
messages: [
|
48
|
+
{ role: 'user', content: prompt },
|
49
|
+
],
|
50
|
+
}
|
51
|
+
)
|
52
|
+
|
53
|
+
# Log the response
|
54
|
+
@logger&.info("RESPONSE: #{response.dig('choices', 0, 'message', 'content')}")
|
55
|
+
|
56
|
+
# Parse the response
|
57
|
+
parse_suggestion(response, available_categories)
|
58
|
+
end
|
59
|
+
|
60
|
+
# Build a prompt for the OpenAI API
|
61
|
+
# @param transaction [Teri::Transaction] The transaction to get a suggestion for
|
62
|
+
# @param accounting [Teri::Accounting] The accounting instance with previous codings
|
63
|
+
# @return [String] The prompt for the OpenAI API
|
64
|
+
def build_prompt(transaction, accounting)
|
65
|
+
prompt = "Transaction details:\n"
|
66
|
+
prompt += "Date: #{transaction.date}\n"
|
67
|
+
prompt += "Description: #{transaction.description}\n"
|
68
|
+
|
69
|
+
prompt += "Memo: #{transaction.memo}\n" if transaction.respond_to?(:memo) && transaction.memo
|
70
|
+
|
71
|
+
prompt += "Amount: #{transaction.amount}\n" if transaction.respond_to?(:amount)
|
72
|
+
|
73
|
+
if transaction.respond_to?(:counterparty) && transaction.counterparty
|
74
|
+
prompt += "Counterparty: #{transaction.counterparty}\n"
|
75
|
+
end
|
76
|
+
|
77
|
+
if transaction.respond_to?(:hints) && transaction.hints && !transaction.hints.empty?
|
78
|
+
prompt += "\nHints from previous categorizations:\n"
|
79
|
+
transaction.hints.each do |hint|
|
80
|
+
prompt += "- #{hint}\n"
|
81
|
+
end
|
82
|
+
end
|
83
|
+
|
84
|
+
# Add previous codings section if available
|
85
|
+
if accounting.respond_to?(:previous_codings) && accounting.previous_codings && !accounting.previous_codings.empty?
|
86
|
+
prompt += "\nPrevious codings:\n"
|
87
|
+
accounting.previous_codings.each do |desc, info|
|
88
|
+
next unless info.is_a?(Hash)
|
89
|
+
|
90
|
+
prompt += if info[:hints] && !info[:hints].empty?
|
91
|
+
"- \"#{desc}\" => #{info[:category]} (hints: #{info[:hints].join(', ')})\n"
|
92
|
+
else
|
93
|
+
"- \"#{desc}\" => #{info[:category]}\n"
|
94
|
+
end
|
95
|
+
end
|
96
|
+
end
|
97
|
+
|
98
|
+
# Add counterparty hints if available
|
99
|
+
if accounting.respond_to?(:counterparty_hints) &&
|
100
|
+
transaction.respond_to?(:counterparty) &&
|
101
|
+
transaction.counterparty &&
|
102
|
+
accounting.counterparty_hints[transaction.counterparty]
|
103
|
+
prompt += "\nCounterparty information:\n"
|
104
|
+
accounting.counterparty_hints[transaction.counterparty].each do |hint|
|
105
|
+
prompt += "- #{hint}\n"
|
106
|
+
end
|
107
|
+
end
|
108
|
+
|
109
|
+
prompt += "\nPlease respond with a JSON object containing:\n"
|
110
|
+
prompt += "- category: The suggested category (e.g., 'Expenses:Office')\n"
|
111
|
+
prompt += "- confidence: A number between 0-100 indicating your confidence\n"
|
112
|
+
prompt += "- explanation: A brief explanation of your suggestion\n"
|
113
|
+
|
114
|
+
prompt
|
115
|
+
end
|
116
|
+
|
117
|
+
private
|
118
|
+
|
119
|
+
# Set up the logger
|
120
|
+
# @param log_file [String] The path to the log file
|
121
|
+
def setup_logger(log_file = nil)
|
122
|
+
# Skip logger setup if we're in a test environment
|
123
|
+
if defined?(RSpec)
|
124
|
+
@logger = nil
|
125
|
+
return
|
126
|
+
end
|
127
|
+
|
128
|
+
begin
|
129
|
+
# Create logs directory if it doesn't exist
|
130
|
+
FileUtils.mkdir_p('logs')
|
131
|
+
|
132
|
+
# Use the provided log file or create a default one with timestamp
|
133
|
+
log_file ||= "logs/openai_#{Time.now.strftime('%Y%m%d_%H%M%S')}.log"
|
134
|
+
|
135
|
+
@logger = Logger.new(log_file)
|
136
|
+
@logger.level = Logger::INFO
|
137
|
+
@logger.formatter = proc do |severity, datetime, _progname, msg|
|
138
|
+
"#{datetime.strftime('%Y-%m-%d %H:%M:%S')} [#{severity}] #{msg}\n"
|
139
|
+
end
|
140
|
+
|
141
|
+
@logger.info('OpenAI client initialized')
|
142
|
+
rescue StandardError => e
|
143
|
+
puts "Warning: Failed to set up OpenAI logging: #{e.message}"
|
144
|
+
@logger = nil
|
145
|
+
end
|
146
|
+
end
|
147
|
+
|
148
|
+
# Parse the suggestion from the OpenAI API response
|
149
|
+
# @param response [Hash] The response from the OpenAI API
|
150
|
+
# @param available_categories [Array<String>] A list of available categories
|
151
|
+
# @return [Hash] A hash containing the suggested category and confidence score
|
152
|
+
def parse_suggestion(response, available_categories = [])
|
153
|
+
content = response.dig('choices', 0, 'message', 'content')
|
154
|
+
return { category: 'Expenses:Unknown', confidence: 0, explanation: 'Failed to parse AI response' } unless content
|
155
|
+
|
156
|
+
begin
|
157
|
+
# Parse the JSON response
|
158
|
+
suggestion = JSON.parse(content, symbolize_names: true)
|
159
|
+
|
160
|
+
# Set default values if missing
|
161
|
+
suggestion[:category] ||= 'Expenses:Unknown'
|
162
|
+
suggestion[:confidence] ||= 0
|
163
|
+
suggestion[:explanation] ||= 'No explanation provided'
|
164
|
+
|
165
|
+
# Convert confidence to a float between 0 and 1 if it's a percentage
|
166
|
+
if suggestion[:confidence].is_a?(Numeric) && suggestion[:confidence] > 1
|
167
|
+
suggestion[:confidence] = suggestion[:confidence].to_f / 100.0
|
168
|
+
end
|
169
|
+
|
170
|
+
# Find the closest match if the category is not in the available categories
|
171
|
+
if !available_categories.empty? && !available_categories.include?(suggestion[:category])
|
172
|
+
original_category = suggestion[:category]
|
173
|
+
suggestion[:category] = find_closest_match(original_category, available_categories)
|
174
|
+
suggestion[:explanation] += " (Adjusted from '#{original_category}' to closest match '#{suggestion[:category]}')"
|
175
|
+
end
|
176
|
+
|
177
|
+
suggestion
|
178
|
+
rescue JSON::ParserError
|
179
|
+
{ category: 'Expenses:Unknown', confidence: 0, explanation: 'Failed to parse AI response' }
|
180
|
+
end
|
181
|
+
end
|
182
|
+
|
183
|
+
# Find the closest match for a category in the available categories
|
184
|
+
# @param category [String] The category to find a match for
|
185
|
+
# @param available_categories [Array<String>] A list of available categories
|
186
|
+
# @return [String] The closest matching category
|
187
|
+
def find_closest_match(category, available_categories)
|
188
|
+
return category if available_categories.empty?
|
189
|
+
|
190
|
+
# Find the category with the smallest Levenshtein distance
|
191
|
+
closest_match = available_categories.min_by do |available_category|
|
192
|
+
levenshtein_distance(category, available_category)
|
193
|
+
end
|
194
|
+
|
195
|
+
closest_match || category
|
196
|
+
end
|
197
|
+
|
198
|
+
# Calculate the Levenshtein distance between two strings
|
199
|
+
# @param str1 [String] The first string
|
200
|
+
# @param str2 [String] The second string
|
201
|
+
# @return [Integer] The Levenshtein distance
|
202
|
+
def levenshtein_distance(str1, str2)
|
203
|
+
m = str1.length
|
204
|
+
n = str2.length
|
205
|
+
|
206
|
+
# Create a matrix of size (m+1) x (n+1)
|
207
|
+
d = Array.new(m + 1) { Array.new(n + 1, 0) }
|
208
|
+
|
209
|
+
# Initialize the first row and column
|
210
|
+
(0..m).each { |i| d[i][0] = i }
|
211
|
+
(0..n).each { |j| d[0][j] = j }
|
212
|
+
|
213
|
+
# Fill in the rest of the matrix
|
214
|
+
(1..m).each do |i|
|
215
|
+
(1..n).each do |j|
|
216
|
+
cost = str1[i - 1] == str2[j - 1] ? 0 : 1
|
217
|
+
d[i][j] = [
|
218
|
+
d[i - 1][j] + 1, # deletion
|
219
|
+
d[i][j - 1] + 1, # insertion
|
220
|
+
d[i - 1][j - 1] + cost, # substitution
|
221
|
+
].min
|
222
|
+
end
|
223
|
+
end
|
224
|
+
|
225
|
+
# Return the distance
|
226
|
+
d[m][n]
|
227
|
+
end
|
228
|
+
end
|
229
|
+
end
|
@@ -0,0 +1,258 @@
|
|
1
|
+
require 'English'
|
2
|
+
require 'date'
|
3
|
+
|
4
|
+
module Teri
|
5
|
+
# Handles report generation for balance sheets and income statements
|
6
|
+
class ReportGenerator
|
7
|
+
def initialize(options, logger)
|
8
|
+
@options = options
|
9
|
+
@logger = logger
|
10
|
+
end
|
11
|
+
|
12
|
+
def generate_balance_sheet(options = {})
|
13
|
+
# Merge provided options with default options
|
14
|
+
opts = @options.merge(options)
|
15
|
+
|
16
|
+
# Get the specified year from options, defaulting to current year
|
17
|
+
specified_year = opts[:year] || Date.today.year
|
18
|
+
|
19
|
+
# Get the number of periods from options, defaulting to 2 if not specified
|
20
|
+
periods = opts[:periods] || 2
|
21
|
+
|
22
|
+
puts "Balance Sheet for #{specified_year}"
|
23
|
+
if opts[:month]
|
24
|
+
puts "Month: #{opts[:month]}"
|
25
|
+
else
|
26
|
+
puts "Including previous #{periods} years"
|
27
|
+
end
|
28
|
+
puts '=' * 50
|
29
|
+
puts ''
|
30
|
+
|
31
|
+
# Check if coding.ledger exists
|
32
|
+
unless File.exist?('coding.ledger')
|
33
|
+
puts "Warning: coding.ledger file does not exist. Please run 'teri code' first to process and code your transactions."
|
34
|
+
puts 'Without coding, the balance sheet cannot be generated correctly.'
|
35
|
+
return
|
36
|
+
end
|
37
|
+
|
38
|
+
# Common ledger options for consistent formatting
|
39
|
+
ledger_options = '--exchange USD --no-total --collapse'
|
40
|
+
|
41
|
+
# If a specific month is provided, show just that month
|
42
|
+
if opts[:month]
|
43
|
+
month = opts[:month].to_i
|
44
|
+
end_date = Date.new(specified_year, month, -1) # Last day of the month
|
45
|
+
|
46
|
+
puts "Balance Sheet as of #{end_date.strftime('%Y-%m-%d')}"
|
47
|
+
puts '-' * 50
|
48
|
+
|
49
|
+
cmd = 'ledger -f coding.ledger'
|
50
|
+
Dir.glob('transactions/*.ledger').each do |file|
|
51
|
+
cmd += " -f #{file}"
|
52
|
+
end
|
53
|
+
cmd += " balance #{ledger_options} --end #{end_date.strftime('%Y/%m/%d')} ^Assets ^Liabilities ^Equity"
|
54
|
+
|
55
|
+
output = `#{cmd}`
|
56
|
+
if $?.success?
|
57
|
+
puts output
|
58
|
+
|
59
|
+
# Check if the balance sheet is balanced
|
60
|
+
if balance_sheet_unbalanced?(output)
|
61
|
+
puts "Note: The balance sheet is not balanced. You may want to run 'teri fix-balance' to create adjustment entries."
|
62
|
+
end
|
63
|
+
else
|
64
|
+
puts "Error generating balance sheet (exit code: #{$?.exitstatus})"
|
65
|
+
puts "Command was: #{cmd}"
|
66
|
+
end
|
67
|
+
|
68
|
+
return
|
69
|
+
end
|
70
|
+
|
71
|
+
# Generate balance sheet for the specified year and previous periods
|
72
|
+
start_year = specified_year - periods
|
73
|
+
end_year = specified_year - 1
|
74
|
+
|
75
|
+
# Show previous years
|
76
|
+
(start_year..end_year).each do |year|
|
77
|
+
puts "Balance Sheet as of #{year}-12-31"
|
78
|
+
puts '-' * 50
|
79
|
+
|
80
|
+
# Use the --file option to specify files instead of a glob pattern
|
81
|
+
cmd = 'ledger -f coding.ledger'
|
82
|
+
Dir.glob('transactions/*.ledger').each do |file|
|
83
|
+
cmd += " -f #{file}"
|
84
|
+
end
|
85
|
+
cmd += " balance #{ledger_options} --end #{year}/12/31 ^Assets ^Liabilities ^Equity"
|
86
|
+
|
87
|
+
output = `#{cmd}`
|
88
|
+
if $?.success?
|
89
|
+
puts output
|
90
|
+
|
91
|
+
# Check if the balance sheet is balanced
|
92
|
+
if balance_sheet_unbalanced?(output)
|
93
|
+
puts "Note: The balance sheet is not balanced. You may want to run 'teri fix-balance' to create adjustment entries."
|
94
|
+
end
|
95
|
+
else
|
96
|
+
puts "Error generating balance sheet (exit code: #{$?.exitstatus})"
|
97
|
+
puts "Command was: #{cmd}"
|
98
|
+
end
|
99
|
+
end
|
100
|
+
|
101
|
+
# Show current balance sheet for specified year
|
102
|
+
current_date = Date.today
|
103
|
+
if specified_year == current_date.year
|
104
|
+
# If specified year is current year, show as of today
|
105
|
+
puts "Balance Sheet as of #{current_date.strftime('%Y-%m-%d')}"
|
106
|
+
end_date = current_date
|
107
|
+
else
|
108
|
+
# If specified year is not current year, show as of year end
|
109
|
+
puts "Balance Sheet as of #{specified_year}-12-31"
|
110
|
+
end_date = Date.new(specified_year, 12, 31)
|
111
|
+
end
|
112
|
+
puts '-' * 50
|
113
|
+
|
114
|
+
cmd = 'ledger -f coding.ledger'
|
115
|
+
Dir.glob('transactions/*.ledger').each do |file|
|
116
|
+
cmd += " -f #{file}"
|
117
|
+
end
|
118
|
+
cmd += " balance #{ledger_options} --end #{end_date.strftime('%Y/%m/%d')} ^Assets ^Liabilities ^Equity"
|
119
|
+
|
120
|
+
output = `#{cmd}`
|
121
|
+
if $?.success?
|
122
|
+
puts output
|
123
|
+
|
124
|
+
# Check if the balance sheet is balanced
|
125
|
+
if balance_sheet_unbalanced?(output)
|
126
|
+
puts "Note: The balance sheet is not balanced. You may want to run 'teri fix-balance' to create adjustment entries."
|
127
|
+
end
|
128
|
+
else
|
129
|
+
puts "Error generating balance sheet (exit code: #{$?.exitstatus})"
|
130
|
+
puts "Command was: #{cmd}"
|
131
|
+
end
|
132
|
+
end
|
133
|
+
|
134
|
+
# Helper method to check if a balance sheet is unbalanced
|
135
|
+
def balance_sheet_unbalanced?(output)
|
136
|
+
# With --no-total option, we need to calculate the total ourselves
|
137
|
+
assets = 0.0
|
138
|
+
liabilities = 0.0
|
139
|
+
equity = 0.0
|
140
|
+
|
141
|
+
# Extract amounts for each section
|
142
|
+
output.each_line do |line|
|
143
|
+
case line
|
144
|
+
when /^\s*([\-\$\d,\.]+)\s+USD\s+Assets/
|
145
|
+
assets_str = ::Regexp.last_match(1).gsub(/[\$,]/, '')
|
146
|
+
assets = assets_str.to_f
|
147
|
+
when /^\s*([\-\$\d,\.]+)\s+USD\s+Liabilities/
|
148
|
+
liabilities_str = ::Regexp.last_match(1).gsub(/[\$,]/, '')
|
149
|
+
liabilities = liabilities_str.to_f
|
150
|
+
when /^\s*([\-\$\d,\.]+)\s+USD\s+Equity/
|
151
|
+
equity_str = ::Regexp.last_match(1).gsub(/[\$,]/, '')
|
152
|
+
equity = equity_str.to_f
|
153
|
+
end
|
154
|
+
end
|
155
|
+
|
156
|
+
# Calculate the balance (Assets = Liabilities + Equity)
|
157
|
+
balance = assets - (liabilities + equity)
|
158
|
+
|
159
|
+
# Consider it balanced if the difference is less than 1 cent
|
160
|
+
balance.abs > 0.01
|
161
|
+
end
|
162
|
+
|
163
|
+
def generate_income_statement(options = {})
|
164
|
+
# Merge provided options with default options
|
165
|
+
opts = @options.merge(options)
|
166
|
+
|
167
|
+
# Get the specified year from options, defaulting to current year
|
168
|
+
specified_year = opts[:year] || Date.today.year
|
169
|
+
|
170
|
+
# Get the number of periods from options, defaulting to 2 if not specified
|
171
|
+
periods = opts[:periods] || 2
|
172
|
+
|
173
|
+
puts "Income Statement for #{specified_year}"
|
174
|
+
if opts[:month]
|
175
|
+
puts "Month: #{opts[:month]}"
|
176
|
+
else
|
177
|
+
puts "Including previous #{periods} years"
|
178
|
+
end
|
179
|
+
puts '=' * 50
|
180
|
+
puts ''
|
181
|
+
|
182
|
+
# Check if coding.ledger exists
|
183
|
+
unless File.exist?('coding.ledger')
|
184
|
+
puts "Warning: coding.ledger file does not exist. Please run 'teri code' first to process and code your transactions."
|
185
|
+
puts 'Without coding, the income statement cannot be generated correctly.'
|
186
|
+
return
|
187
|
+
end
|
188
|
+
|
189
|
+
# Common ledger options for consistent formatting
|
190
|
+
ledger_options = '--exchange USD --no-total --collapse'
|
191
|
+
|
192
|
+
# If a specific month is provided, show just that month
|
193
|
+
if opts[:month]
|
194
|
+
month = opts[:month].to_i
|
195
|
+
start_date = Date.new(specified_year, month, 1)
|
196
|
+
end_date = Date.new(specified_year, month, -1) # Last day of the month
|
197
|
+
|
198
|
+
puts "Income Statement for #{start_date.strftime('%B %Y')}"
|
199
|
+
puts '-' * 50
|
200
|
+
|
201
|
+
cmd = 'ledger -f coding.ledger'
|
202
|
+
Dir.glob('transactions/*.ledger').each do |file|
|
203
|
+
cmd += " -f #{file}"
|
204
|
+
end
|
205
|
+
cmd += " balance #{ledger_options} --begin #{start_date.strftime('%Y/%m/%d')} --end #{end_date.strftime('%Y/%m/%d')} ^Income ^Expenses"
|
206
|
+
|
207
|
+
else
|
208
|
+
# Generate income statement for the specified year and previous periods
|
209
|
+
start_year = specified_year - periods
|
210
|
+
end_year = specified_year - 1
|
211
|
+
|
212
|
+
# Show previous years
|
213
|
+
(start_year..end_year).each do |year|
|
214
|
+
puts "Income Statement for #{year}"
|
215
|
+
puts '-' * 50
|
216
|
+
|
217
|
+
cmd = 'ledger -f coding.ledger'
|
218
|
+
Dir.glob('transactions/*.ledger').each do |file|
|
219
|
+
cmd += " -f #{file}"
|
220
|
+
end
|
221
|
+
cmd += " balance #{ledger_options} --begin #{year}/01/01 --end #{year}/12/31 ^Income ^Expenses"
|
222
|
+
|
223
|
+
output = `#{cmd}`
|
224
|
+
if $?.success?
|
225
|
+
puts output
|
226
|
+
else
|
227
|
+
puts "Error generating income statement (exit code: #{$?.exitstatus})"
|
228
|
+
puts "Command was: #{cmd}"
|
229
|
+
end
|
230
|
+
end
|
231
|
+
|
232
|
+
# Show current year income statement
|
233
|
+
current_date = Date.today
|
234
|
+
if specified_year == current_date.year
|
235
|
+
# If specified year is current year, show as of today
|
236
|
+
puts "Income Statement as of #{current_date.strftime('%Y-%m-%d')}"
|
237
|
+
else
|
238
|
+
# If specified year is not current year, show as of year end
|
239
|
+
puts "Income Statement as of #{specified_year}-12-31"
|
240
|
+
end
|
241
|
+
|
242
|
+
cmd = 'ledger -f coding.ledger'
|
243
|
+
Dir.glob('transactions/*.ledger').each do |file|
|
244
|
+
cmd += " -f #{file}"
|
245
|
+
end
|
246
|
+
cmd += " balance #{ledger_options} --begin #{specified_year}/01/01 --end #{specified_year}/12/31 ^Income ^Expenses"
|
247
|
+
end
|
248
|
+
|
249
|
+
output = `#{cmd}`
|
250
|
+
if $?.success?
|
251
|
+
puts output
|
252
|
+
else
|
253
|
+
puts "Error generating income statement (exit code: #{$?.exitstatus})"
|
254
|
+
puts "Command was: #{cmd}"
|
255
|
+
end
|
256
|
+
end
|
257
|
+
end
|
258
|
+
end
|