money_tracking 0.99.0

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.
Files changed (49) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +16 -0
  3. data/.rspec +2 -0
  4. data/.travis.yml +8 -0
  5. data/Gemfile +10 -0
  6. data/LICENSE.txt +22 -0
  7. data/README.md +61 -0
  8. data/Rakefile +2 -0
  9. data/bin/money +6 -0
  10. data/lib/money_tracking.rb +6 -0
  11. data/lib/money_tracking/cli.rb +88 -0
  12. data/lib/money_tracking/cli/commands.rb +7 -0
  13. data/lib/money_tracking/cli/create_command.rb +21 -0
  14. data/lib/money_tracking/cli/delete_command.rb +15 -0
  15. data/lib/money_tracking/cli/list_command.rb +16 -0
  16. data/lib/money_tracking/cli/runner.rb +54 -0
  17. data/lib/money_tracking/cli/update_command.rb +33 -0
  18. data/lib/money_tracking/cli/views.rb +15 -0
  19. data/lib/money_tracking/cli/views/empty.rb +11 -0
  20. data/lib/money_tracking/cli/views/expense_created.rb +11 -0
  21. data/lib/money_tracking/cli/views/expense_deleted.rb +15 -0
  22. data/lib/money_tracking/cli/views/expense_item.rb +34 -0
  23. data/lib/money_tracking/cli/views/expense_list.rb +11 -0
  24. data/lib/money_tracking/cli/views/expense_not_found.rb +15 -0
  25. data/lib/money_tracking/cli/views/expense_not_updated.rb +19 -0
  26. data/lib/money_tracking/cli/views/expense_updated.rb +15 -0
  27. data/lib/money_tracking/data_store/file_store.rb +81 -0
  28. data/lib/money_tracking/data_store/protocol.rb +20 -0
  29. data/lib/money_tracking/domain.rb +12 -0
  30. data/lib/money_tracking/domain/expense.rb +92 -0
  31. data/lib/money_tracking/domain/expense_factory.rb +15 -0
  32. data/lib/money_tracking/domain/expense_finder.rb +13 -0
  33. data/lib/money_tracking/version.rb +3 -0
  34. data/money_tracking.gemspec +25 -0
  35. data/spec/aruba_helper.rb +31 -0
  36. data/spec/integration/cli_spec.rb +129 -0
  37. data/spec/money_tracking/cli/create_command_spec.rb +38 -0
  38. data/spec/money_tracking/cli/delete_command_spec.rb +44 -0
  39. data/spec/money_tracking/cli/list_command_spec.rb +34 -0
  40. data/spec/money_tracking/cli/update_command_spec.rb +92 -0
  41. data/spec/money_tracking/cli/views/expense_item_spec.rb +60 -0
  42. data/spec/money_tracking/cli/views/expense_list_spec.rb +40 -0
  43. data/spec/money_tracking/data_store/acts_as_a_data_store.rb +144 -0
  44. data/spec/money_tracking/data_store/file_store_spec.rb +16 -0
  45. data/spec/money_tracking/domain/expense_factory_spec.rb +58 -0
  46. data/spec/money_tracking/domain/expense_finder_spec.rb +59 -0
  47. data/spec/money_tracking/domain/expense_spec.rb +125 -0
  48. data/spec/spec_helper.rb +32 -0
  49. metadata +148 -0
@@ -0,0 +1,34 @@
1
+ module MoneyTracking
2
+ module Cli
3
+ module Views
4
+ class ExpenseItem < Struct.new(:expense)
5
+ def to_s
6
+ "#{id} - #{created_at}: #{amount} #{currency} #{tags}".strip
7
+ end
8
+
9
+ private
10
+
11
+ def id; expense[:id] end
12
+ def currency; expense[:currency] end
13
+
14
+ def created_at
15
+ time_value(expense[:created_at])
16
+ end
17
+
18
+ def amount
19
+ sprintf("%.2f", expense[:amount].to_f.round(2))
20
+ end
21
+
22
+ def tags
23
+ return "" if expense[:tags].empty?
24
+ "[#{expense[:tags].sort.join(" ")}]"
25
+ end
26
+
27
+ def time_value(value)
28
+ return value unless value.is_a?(Time)
29
+ value.strftime("%Y-%m-%d %H:%M:%S")
30
+ end
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,11 @@
1
+ module MoneyTracking
2
+ module Cli
3
+ module Views
4
+ class ExpenseList < Struct.new(:expenses, :item_factory)
5
+ def to_s
6
+ expenses.map { |expense| expense.build_view(item_factory).to_s + "\n" }.join
7
+ end
8
+ end
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,15 @@
1
+ module MoneyTracking
2
+ module Cli
3
+ module Views
4
+ class ExpenseNotFound < Struct.new(:expense)
5
+ def initialize(expense)
6
+ raise Error.new(self)
7
+ end
8
+
9
+ def to_s
10
+ "Not found."
11
+ end
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,19 @@
1
+ module MoneyTracking
2
+ module Cli
3
+ module Views
4
+ class ExpenseNotUpdated < Struct.new(:expense)
5
+ def self.not_found
6
+ self
7
+ end
8
+
9
+ def initialize(expense)
10
+ raise Error.new(self)
11
+ end
12
+
13
+ def to_s
14
+ "Not updated."
15
+ end
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,15 @@
1
+ module MoneyTracking
2
+ module Cli
3
+ module Views
4
+ class ExpenseUpdated < Struct.new(:expense)
5
+ def self.not_found
6
+ ExpenseNotFound
7
+ end
8
+
9
+ def to_s
10
+ "Updated expense #{expense[:id]}."
11
+ end
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,81 @@
1
+ require "securerandom"
2
+ require "yaml"
3
+
4
+ module MoneyTracking
5
+ module DataStore
6
+ class FileStore < Protocol
7
+ def initialize(dir)
8
+ @dir = dir
9
+ `mkdir -p #{dir}`
10
+ end
11
+
12
+ def create(fields)
13
+ Record.new(dir, fields).save.id
14
+ end
15
+
16
+ def read(id)
17
+ Record.new(dir).load(id).fields
18
+ rescue Errno::ENOENT
19
+ nil
20
+ end
21
+
22
+ def list
23
+ Record.list(dir).map { |record| record.fields }
24
+ end
25
+
26
+ def update(id, fields)
27
+ Record.new(dir).load(id).update(fields)
28
+ end
29
+
30
+ def delete(id)
31
+ Record.new(dir).load(id).delete
32
+ end
33
+
34
+ private
35
+
36
+ attr_accessor :dir
37
+
38
+ class Record < Struct.new(:dir, :fields)
39
+ def self.list(dir)
40
+ Dir["#{dir}/*.yml"]
41
+ .map { |path| File.basename(path, ".yml") }
42
+ .map { |id| new(dir).load(id) }
43
+ end
44
+
45
+ def id
46
+ @id ||= SecureRandom.hex(4)
47
+ end
48
+
49
+ def load(id)
50
+ @id = id
51
+ self.fields = YAML.load_file(filename).merge(id: id)
52
+ self
53
+ end
54
+
55
+ def save
56
+ File.open(filename, "w") { |f| f.write(to_yaml) }
57
+ self
58
+ end
59
+
60
+ def update(fields)
61
+ self.fields = fields
62
+ save
63
+ end
64
+
65
+ def delete
66
+ File.delete(filename)
67
+ end
68
+
69
+ private
70
+
71
+ def to_yaml
72
+ fields.to_yaml
73
+ end
74
+
75
+ def filename
76
+ "#{dir}/#{id}.yml"
77
+ end
78
+ end
79
+ end
80
+ end
81
+ end
@@ -0,0 +1,20 @@
1
+ module MoneyTracking
2
+ module DataStore
3
+ class Protocol
4
+ def create(fields)
5
+ end
6
+
7
+ def read(id)
8
+ end
9
+
10
+ def list
11
+ end
12
+
13
+ def update(id, fields)
14
+ end
15
+
16
+ def delete(id)
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,12 @@
1
+ MODELS = %w[
2
+ expense expense_factory expense_finder
3
+ ]
4
+
5
+ MODELS.each do |model|
6
+ require "money_tracking/domain/#{model}"
7
+ end
8
+
9
+ module MoneyTracking
10
+ module Domain
11
+ end
12
+ end
@@ -0,0 +1,92 @@
1
+ module MoneyTracking
2
+ module Domain
3
+ class Expense
4
+ SIMPLE_FIELDS = %i[amount currency]
5
+
6
+ def initialize(store, fields)
7
+ @store = store
8
+ @fields = fields
9
+ @id = fields[:id]
10
+ end
11
+
12
+ def create
13
+ set_created_at
14
+ @id = store.create(raw)
15
+ self
16
+ end
17
+
18
+ def update(updated_fields)
19
+ simple_update(updated_fields)
20
+ update_tags(updated_fields)
21
+ store.update(id, raw)
22
+ self
23
+ end
24
+
25
+ def delete
26
+ store.delete(id)
27
+ self
28
+ end
29
+
30
+ def build_view(view_factory)
31
+ view_factory.new(raw)
32
+ end
33
+
34
+ private
35
+
36
+ attr_reader :store, :fields, :id
37
+
38
+ def raw
39
+ return fields unless id
40
+ fields.merge(id: id)
41
+ end
42
+
43
+ def set_created_at
44
+ fields[:created_at] ||= Time.now
45
+ end
46
+
47
+ def simple_update(updated_fields)
48
+ SIMPLE_FIELDS.each do |name|
49
+ fields[name] = updated_fields[name] if updated_fields[name]
50
+ end
51
+ end
52
+
53
+ def update_tags(updated_fields)
54
+ fields[:tags] = TagUpdate.new(
55
+ fields[:tags],
56
+ updated_fields[:add_tags],
57
+ updated_fields[:rm_tags],
58
+ ).value
59
+ end
60
+
61
+ class TagUpdate < Struct.new(:tags, :add_tags, :rm_tags)
62
+ def value
63
+ tags - rm_tags + add_tags
64
+ end
65
+
66
+ private
67
+
68
+ def rm_tags
69
+ super || []
70
+ end
71
+
72
+ def add_tags
73
+ super || []
74
+ end
75
+ end
76
+ end
77
+
78
+ class ExpenseNotFound
79
+ def build_view(view_factory)
80
+ view_factory.not_found.new(self)
81
+ end
82
+
83
+ def update(updated_fields)
84
+ self
85
+ end
86
+
87
+ def delete
88
+ self
89
+ end
90
+ end
91
+ end
92
+ end
@@ -0,0 +1,15 @@
1
+ module MoneyTracking
2
+ module Domain
3
+ class ExpenseFactory < Struct.new(:store)
4
+ def create(raw_expense)
5
+ build(raw_expense).create
6
+ end
7
+
8
+ def build(raw_expense, id = nil)
9
+ return ExpenseNotFound.new unless raw_expense
10
+ return build(raw_expense.merge(id: id)) if id
11
+ Expense.new(store, raw_expense)
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,13 @@
1
+ module MoneyTracking
2
+ module Domain
3
+ class ExpenseFinder < Struct.new(:store, :expense_factory)
4
+ def list
5
+ store.list.map { |raw| expense_factory.build(raw) }
6
+ end
7
+
8
+ def read(expense_id)
9
+ expense_factory.build(store.read(expense_id), expense_id)
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,3 @@
1
+ module MoneyTracking
2
+ VERSION = "0.99.0"
3
+ end
@@ -0,0 +1,25 @@
1
+ # coding: utf-8
2
+ lib = File.expand_path('../lib', __FILE__)
3
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
4
+ require 'money_tracking/version'
5
+
6
+ Gem::Specification.new do |spec|
7
+ spec.name = "money_tracking"
8
+ spec.version = MoneyTracking::VERSION
9
+ spec.authors = ["Oleksii Fedorov"]
10
+ spec.email = ["waterlink000@gmail.com"]
11
+ spec.summary = %q{CLI tool for tracking your expenses.}
12
+ spec.description = %q{CLI tool for tracking your expenses.}
13
+ spec.homepage = "https://github.com/waterlink/money_tracking"
14
+ spec.license = "MIT"
15
+
16
+ spec.files = `git ls-files -z`.split("\x0")
17
+ spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) }
18
+ spec.test_files = spec.files.grep(%r{^(test|spec|features)/})
19
+ spec.require_paths = ["lib"]
20
+
21
+ spec.add_dependency "thor"
22
+
23
+ spec.add_development_dependency "bundler", "~> 1.7"
24
+ spec.add_development_dependency "rake", "~> 10.0"
25
+ end
@@ -0,0 +1,31 @@
1
+ require "aruba/api"
2
+ require "aruba/reporting"
3
+ require "aruba/in_process"
4
+
5
+ require "money_tracking/cli/runner"
6
+
7
+ Aruba::InProcess.main_class = MoneyTracking::Cli::Runner
8
+ Aruba.process = Aruba::InProcess
9
+
10
+ # https://github.com/cucumber/aruba/issues/236
11
+ class Aruba::InProcess
12
+ def output
13
+ stdout + stderr
14
+ end
15
+ end
16
+
17
+ ENV["TEST_HOME"] ||= "#{Dir.getwd}/tmp/aruba/"
18
+
19
+ module ArubaHelper
20
+ include Aruba::Api
21
+
22
+ def self.included(base)
23
+ base.before(:each) do
24
+ @aruba_io_wait_seconds = 5
25
+ @aruba_timeout_seconds = 5
26
+
27
+ restore_env
28
+ clean_current_dir
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,129 @@
1
+ require "aruba_helper"
2
+
3
+ RSpec.describe "Cli presentation layer" do
4
+ include ArubaHelper
5
+
6
+ example "Basic example" do
7
+ money "expenses list"
8
+ expect(output).to match("Empty.")
9
+
10
+ money "expenses", "create", "73.9", "euro", "food"
11
+ expect(output).to match(%r{Created new expense with id ([\d\w]{8}).})
12
+
13
+ id = output.scan(/[\d\w]{8}/)[0]
14
+
15
+ money "expenses", "list"
16
+ expect(output).to match(
17
+ %r{#{id} - ....-..-.. ..:..:..: 73\.90 euro \[food\]}
18
+ )
19
+
20
+ money "expenses", "update", id, "--amount", "73.95"
21
+ expect(output).to match("Updated expense #{id}.")
22
+
23
+ money "expenses", "list"
24
+ expect(output).to match(
25
+ %r{#{id} - ....-..-.. ..:..:..: 73\.95 euro \[food\]}
26
+ )
27
+
28
+ money "expenses", "delete", id
29
+ expect(output).to match("Deleted expense #{id}.")
30
+
31
+ money "expenses list"
32
+ expect(output).to match("Empty.")
33
+ end
34
+
35
+ example "Not found on update" do
36
+ money "expenses", "update", "hello_world", "--amount", "99.99", expected_exit_status: 1
37
+ expect(output).to match("Not found.")
38
+ end
39
+
40
+ example "Not found on delete" do
41
+ money "expenses", "delete", "hello_world", expected_exit_status: 1
42
+ expect(output).to match("Not found.")
43
+ end
44
+
45
+ example "Not updated on update" do
46
+ money "expenses", "create", "25", "euro", "internet"
47
+ id = output.scan(/[\d\w]{8}/)[0]
48
+
49
+ money "expenses", "update", id, expected_exit_status: 1
50
+ expect(output).to match("Not updated.")
51
+ end
52
+
53
+ describe "update functionality" do
54
+ let!(:id) do
55
+ money "expenses", "create", "75.39", "euro", "food", "italian"
56
+ output.scan(/[\d\w]{8}/)[0]
57
+ end
58
+
59
+ example "updating amount only" do
60
+ money "expenses", "update", id, "--amount", "74.99"
61
+ money "expenses", "list"
62
+ expect(output).to match(
63
+ %r{#{id} - ....-..-.. ..:..:..: 74\.99 euro \[food italian\]}
64
+ )
65
+ end
66
+
67
+ example "updating currency only" do
68
+ money "expenses", "update", id, "--currency", "eur"
69
+ money "expenses", "list"
70
+ expect(output).to match(
71
+ %r{#{id} - ....-..-.. ..:..:..: 75\.39 eur \[food italian\]}
72
+ )
73
+ end
74
+
75
+ example "adding tags only" do
76
+ money "expenses", "update", id, "--add-tags", "tasty", "pricey"
77
+ money "expenses", "list"
78
+ expect(output).to match(
79
+ %r{#{id} - ....-..-.. ..:..:..: 75\.39 euro \[food italian pricey tasty\]}
80
+ )
81
+ end
82
+
83
+ example "removing tags only" do
84
+ money "expenses", "update", id, "--rm-tags", "food"
85
+ money "expenses", "list"
86
+ expect(output).to match(
87
+ %r{#{id} - ....-..-.. ..:..:..: 75\.39 euro \[italian\]}
88
+ )
89
+ end
90
+
91
+ example "removing and adding tags only" do
92
+ money "expenses", "update", id, "--rm-tags", "food", "--add-tags", "restaurant", "awesome"
93
+ money "expenses", "list"
94
+ expect(output).to match(
95
+ %r{#{id} - ....-..-.. ..:..:..: 75\.39 euro \[awesome italian restaurant\]}
96
+ )
97
+ end
98
+
99
+ example "updating all fields at once" do
100
+ money "expenses", "update", id,
101
+ "--rm-tags", "food",
102
+ "--amount", "95.49",
103
+ "--currency=dollar",
104
+ "--add-tags", "restaurant", "awesome"
105
+
106
+ money "expenses", "list"
107
+ expect(output).to match(
108
+ %r{#{id} - ....-..-.. ..:..:..: 95\.49 dollar \[awesome italian restaurant\]}
109
+ )
110
+ end
111
+ end
112
+
113
+ private
114
+
115
+ def money(*args, expected_exit_status: 0)
116
+ command = money_command(*args)
117
+ run_simple(command, false)
118
+ @last_output = output_from(command)
119
+ assert_exit_status(expected_exit_status)
120
+ end
121
+
122
+ def money_command(*args)
123
+ "money #{args.join(" ")}"
124
+ end
125
+
126
+ def output
127
+ @last_output
128
+ end
129
+ end