aloe 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (74) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +20 -0
  3. data/Rakefile +26 -0
  4. data/lib/aloe.rb +14 -0
  5. data/lib/aloe/account.rb +176 -0
  6. data/lib/aloe/account_configuration.rb +21 -0
  7. data/lib/aloe/account_repository.rb +45 -0
  8. data/lib/aloe/engine.rb +6 -0
  9. data/lib/aloe/entry.rb +59 -0
  10. data/lib/aloe/inoperable_account_error.rb +11 -0
  11. data/lib/aloe/insufficient_balance_error.rb +14 -0
  12. data/lib/aloe/invalid_amount_error.rb +3 -0
  13. data/lib/aloe/invalid_currency_error.rb +16 -0
  14. data/lib/aloe/ledger.rb +83 -0
  15. data/lib/aloe/ledger_entry.rb +83 -0
  16. data/lib/aloe/reports/account_history.rb +32 -0
  17. data/lib/aloe/reports/accounts_list.rb +39 -0
  18. data/lib/aloe/transaction.rb +80 -0
  19. data/lib/aloe/transaction_rollback.rb +34 -0
  20. data/lib/aloe/version.rb +3 -0
  21. data/lib/generators/aloe/aloe_generator.rb +17 -0
  22. data/lib/generators/aloe/templates/migration.rb +44 -0
  23. data/lib/tasks/aloe_tasks.rake +24 -0
  24. data/spec/aloe/account_configuration_spec.rb +21 -0
  25. data/spec/aloe/account_integration_spec.rb +144 -0
  26. data/spec/aloe/account_spec.rb +189 -0
  27. data/spec/aloe/accounting_integration_spec.rb +172 -0
  28. data/spec/aloe/entry_spec.rb +43 -0
  29. data/spec/aloe/ledger_entry_spec.rb +105 -0
  30. data/spec/aloe/ledger_spec.rb +98 -0
  31. data/spec/aloe/transaction_rollback_spec.rb +49 -0
  32. data/spec/aloe/transaction_spec.rb +59 -0
  33. data/spec/dummy/Rakefile +6 -0
  34. data/spec/dummy/app/assets/javascripts/application.js +13 -0
  35. data/spec/dummy/app/assets/stylesheets/application.css +13 -0
  36. data/spec/dummy/app/controllers/application_controller.rb +5 -0
  37. data/spec/dummy/app/helpers/application_helper.rb +2 -0
  38. data/spec/dummy/app/models/user.rb +2 -0
  39. data/spec/dummy/app/views/layouts/application.html.erb +14 -0
  40. data/spec/dummy/bin/bundle +3 -0
  41. data/spec/dummy/bin/rails +4 -0
  42. data/spec/dummy/bin/rake +4 -0
  43. data/spec/dummy/config.ru +4 -0
  44. data/spec/dummy/config/application.rb +23 -0
  45. data/spec/dummy/config/boot.rb +5 -0
  46. data/spec/dummy/config/database.yml +25 -0
  47. data/spec/dummy/config/environment.rb +5 -0
  48. data/spec/dummy/config/environments/development.rb +29 -0
  49. data/spec/dummy/config/environments/production.rb +80 -0
  50. data/spec/dummy/config/environments/test.rb +36 -0
  51. data/spec/dummy/config/initializers/backtrace_silencers.rb +7 -0
  52. data/spec/dummy/config/initializers/filter_parameter_logging.rb +4 -0
  53. data/spec/dummy/config/initializers/inflections.rb +16 -0
  54. data/spec/dummy/config/initializers/mime_types.rb +5 -0
  55. data/spec/dummy/config/initializers/secret_token.rb +12 -0
  56. data/spec/dummy/config/initializers/session_store.rb +3 -0
  57. data/spec/dummy/config/initializers/wrap_parameters.rb +14 -0
  58. data/spec/dummy/config/locales/en.yml +23 -0
  59. data/spec/dummy/config/routes.rb +56 -0
  60. data/spec/dummy/db/development.sqlite3 +0 -0
  61. data/spec/dummy/db/migrate/20131003200954_create_users.rb +8 -0
  62. data/spec/dummy/db/migrate/20131003203647_create_aloe_tables.rb +43 -0
  63. data/spec/dummy/db/schema.rb +63 -0
  64. data/spec/dummy/log/test.log +3308 -0
  65. data/spec/dummy/public/404.html +58 -0
  66. data/spec/dummy/public/422.html +58 -0
  67. data/spec/dummy/public/500.html +57 -0
  68. data/spec/dummy/public/favicon.ico +0 -0
  69. data/spec/dummy/test/fixtures/users.yml +11 -0
  70. data/spec/dummy/test/models/user_test.rb +7 -0
  71. data/spec/spec_helper.rb +19 -0
  72. data/spec/support/account_helpers.rb +17 -0
  73. data/spec/support/user.rb +1 -0
  74. metadata +278 -0
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA1:
3
+ metadata.gz: f4a87bfd19ba538180dce803adff1c2f305f16da
4
+ data.tar.gz: bb678013f56e69ba1c22f96ed892ff355978545e
5
+ SHA512:
6
+ metadata.gz: a150a76f4770ca11ebb642ad73a6f00b063535cf450ae903ffc9fc8fd4fe23c5672459407fe89925860b494bac524bbb6e0e10d7fc609773e55a601f525ae916
7
+ data.tar.gz: 63166ea20295695082ba9d6f2e43ea905226bfcc23f3fb60ecf32870ee0730f60d2632d8f9491c72a89dbdadd89b3571f14b2b4f9ed09473c3b35de87655d9f0
data/LICENSE ADDED
@@ -0,0 +1,20 @@
1
+ Copyright 2013 YOURNAME
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining
4
+ a copy of this software and associated documentation files (the
5
+ "Software"), to deal in the Software without restriction, including
6
+ without limitation the rights to use, copy, modify, merge, publish,
7
+ distribute, sublicense, and/or sell copies of the Software, and to
8
+ permit persons to whom the Software is furnished to do so, subject to
9
+ the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be
12
+ included in all copies or substantial portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
17
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
18
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
19
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
20
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
@@ -0,0 +1,26 @@
1
+ begin
2
+ require 'bundler/setup'
3
+ rescue LoadError
4
+ puts 'You must `gem install bundler` and `bundle install` to run rake tasks'
5
+ end
6
+
7
+ require 'rdoc/task'
8
+
9
+ RDoc::Task.new(:rdoc) do |rdoc|
10
+ rdoc.rdoc_dir = 'rdoc'
11
+ rdoc.title = 'Aloe'
12
+ rdoc.options << '--line-numbers'
13
+ rdoc.rdoc_files.include('README.rdoc')
14
+ rdoc.rdoc_files.include('lib/**/*.rb')
15
+ end
16
+
17
+ APP_RAKEFILE = File.expand_path("../spec/dummy/Rakefile", __FILE__)
18
+ load 'rails/tasks/engine.rake'
19
+
20
+ Bundler::GemHelper.install_tasks
21
+
22
+ require 'rspec/core/rake_task'
23
+ RSpec::Core::RakeTask.new(:spec)
24
+
25
+ task :default => :spec
26
+
@@ -0,0 +1,14 @@
1
+ require 'aloe/ledger'
2
+ require 'aloe/engine'
3
+ require 'aloe/reports/account_history'
4
+ require 'aloe/reports/accounts_list'
5
+
6
+ module Aloe
7
+
8
+ ROLLBACK_TRANSACTION = 'rollback'.freeze
9
+
10
+ def self.table_name_prefix
11
+ 'aloe_'
12
+ end
13
+
14
+ end
@@ -0,0 +1,176 @@
1
+ require 'aloe/account_repository'
2
+ require 'aloe/account_configuration'
3
+ require 'aloe/entry'
4
+ require 'active_record'
5
+ require 'money'
6
+ require 'state_machine'
7
+ require 'active_support/core_ext/object/with_options'
8
+
9
+ module Aloe
10
+ class Account < ActiveRecord::Base
11
+
12
+ extend Aloe::AccountRepository
13
+
14
+ # Associations
15
+
16
+ belongs_to :owner, polymorphic: true
17
+ has_many :entries, class_name: "Aloe::Entry", include: :account
18
+ has_many :debit_transactions, through: :entries
19
+ has_many :credit_transactions, through: :entries
20
+
21
+ # Validations
22
+
23
+ validates :currency, presence: true
24
+ with_options scope: :currency do |opts|
25
+ opts.validates_uniqueness_of :name, if: ->{ name? }
26
+ end
27
+ validate :uniqueness_of_owner
28
+
29
+ # State machine
30
+
31
+ # An account can have following states:
32
+ # * open - can be manipulated
33
+ # * suspended - cannot be manipulated, needs to be unsuspended first
34
+ # * closed - cannot be manipulated, only with 0 balance
35
+ state_machine :state, initial: :open do
36
+ event :close do
37
+ transition all => :closed, if: :closeable?
38
+ end
39
+
40
+ event :reopen do
41
+ transition :closed => :open
42
+ end
43
+
44
+ event :suspend do
45
+ transition all => :suspended
46
+ end
47
+
48
+ event :unsuspend do
49
+ transition :suspended => :open
50
+ end
51
+ end
52
+
53
+ # Instance methods
54
+
55
+ serialize :configuration, Aloe::AccountConfiguration
56
+
57
+ delegate :allow_negative_balance, to: :configuration
58
+
59
+ # Return the balance of account
60
+ #
61
+ # @return [Money] The balance
62
+ def balance
63
+ Money.new read_attribute(:balance), currency
64
+ end
65
+
66
+ # Computes the balance of the account at the requested date and time.
67
+ # Returns nil if the account did not exist at that time.
68
+ #
69
+ # @param [Time] the date and time
70
+ # @return [Money] the past balance
71
+ def balance_at(time)
72
+ return nil unless created_at <= time
73
+ amount = entries.where('created_at >= ?', time).sum(&:amount)
74
+ offset = amount == 0 ? Money.new(0, currency) : amount
75
+ balance - offset
76
+ end
77
+
78
+ # Does account have minimum given balance?
79
+ #
80
+ # @param [Money, Fixnum] amount Amount in question in cents
81
+ # @param [NilClass, Symbol] option Option
82
+ # @return [true, false]
83
+ def balance_of?(amount, option = nil)
84
+ reload if option == :reload
85
+ cents_amount = amount.respond_to?(:cents) ? amount.cents : amount
86
+ read_attribute(:balance) >= cents_amount
87
+ end
88
+
89
+ # Can the account be closed?
90
+ #
91
+ # An account can be closed only if the balance is 0.
92
+ #
93
+ # @return [true, false]
94
+ def closeable?
95
+ balance.zero?
96
+ end
97
+
98
+ # Creates entry in the account.
99
+ #
100
+ # Creates new entry and modified the balance.
101
+ #
102
+ # @param [Fixnum] cents_amount Amount in cents
103
+ # @return [Aloe::Entry] Created entry
104
+ def create_entry(cents_amount)
105
+ with_lock(true) do
106
+ if cents_amount < 0 && !debit_possible?(-cents_amount)
107
+ raise Aloe::InsufficientBalanceError.new(self, -cents_amount)
108
+ end
109
+ entry = entries.create! amount: cents_amount
110
+ increment! :balance, cents_amount
111
+ entry
112
+ end
113
+ end
114
+
115
+ # Is account in given currency?
116
+ #
117
+ # @param [Currency, Symbol, String] currency_in_question
118
+ # @return [true, false]
119
+ def currency?(currency_in_question)
120
+ currency.to_s == currency_in_question.to_s
121
+ end
122
+
123
+ # Is the debit of given amount possible?
124
+ #
125
+ # @param [Money, Fixnum] amount Amount in question in cents
126
+ # @return [true, false]
127
+ def debit_possible?(amount)
128
+ allow_negative_balance ? true : balance_of?(amount, :reload)
129
+ end
130
+
131
+ # Rolls back all transactions on this account.
132
+ def rollback_all
133
+ transactions = entries.map &:transaction
134
+ transactions.map &:rollback
135
+ end
136
+
137
+ # Return string representation of account.
138
+ #
139
+ # @return [String]
140
+ def to_s
141
+ name? ? name : owner_type_and_id
142
+ end
143
+
144
+ # Return account turnover over given period of time.
145
+ #
146
+ # @param [Range] period
147
+ # @return [Money]
148
+ def turnover(period)
149
+ turnover = entries.where(created_at: period).sum &:amount
150
+ turnover == 0 ? Money.new(0, currency) : turnover
151
+ end
152
+
153
+ # Return account owner type and it's ID.
154
+ #
155
+ # @return [String]
156
+ def owner_type_and_id
157
+ "#{owner_type} #{owner_id}" if owner.present?
158
+ end
159
+
160
+ protected
161
+
162
+ def uniqueness_of_owner
163
+ if has_conflicting_account_for_owner?
164
+ message = I18n.t('aloe.accounts.errors.owner_already_has_account')
165
+ errors.add(:owner, message)
166
+ end
167
+ end
168
+
169
+ def has_conflicting_account_for_owner?
170
+ new_record? && owner.present? &&
171
+ self.class.owner(owner).currency(currency).where(name: name).exists?
172
+ end
173
+
174
+ end
175
+ end
176
+
@@ -0,0 +1,21 @@
1
+ module Aloe
2
+ class AccountConfiguration
3
+
4
+ attr_writer :allow_negative_balance
5
+
6
+ def initialize(attrs = {})
7
+ attrs.each do |k ,v|
8
+ public_send :"#{k}=", v
9
+ end
10
+ end
11
+
12
+ # Does the account allow negative balance?
13
+ # Defaults to true if no values is provided
14
+ #
15
+ # @return [true, false]
16
+ def allow_negative_balance
17
+ @allow_negative_balance == false ? false : true
18
+ end
19
+
20
+ end
21
+ end
@@ -0,0 +1,45 @@
1
+ module Aloe
2
+ module AccountRepository
3
+
4
+ # Scope by currency.
5
+ #
6
+ # @param [String, Symbol] currency Currency symbol
7
+ # @return [ActiveRecord::Relation]
8
+ def currency(currency)
9
+ where(currency: currency)
10
+ end
11
+
12
+ # Default scope excludes closed accounts.
13
+ def default_scope
14
+ where('state != ?', 'closed')
15
+ end
16
+
17
+ # Scope to closed accounts.
18
+ #
19
+ # @return [ActiveRecord::Relation]
20
+ def closed
21
+ unscoped.with_state('closed')
22
+ end
23
+
24
+ # Scope by owner.
25
+ #
26
+ # @param [ActiveRecord::Base] owner
27
+ # @return [ActiveRecord::Relation]
28
+ def owner(owner)
29
+ where(owner_type: owner.class.model_name.to_s, owner_id: owner.id)
30
+ end
31
+
32
+ # Return the trial balance.
33
+ #
34
+ # Trial balance is balance of all accounts in the system
35
+ # combined. It should at all times be 0. If it's not, there
36
+ # is an error in accounts somewhere.
37
+ #
38
+ # @param [String, Symbol] currency Currency symbol
39
+ # @return [Fixnum] Zero if everything's fine
40
+ def trial_balance(currency)
41
+ currency(currency).sum :balance
42
+ end
43
+
44
+ end
45
+ end
@@ -0,0 +1,6 @@
1
+ module Aloe
2
+
3
+ class Engine < Rails::Engine
4
+ end
5
+
6
+ end
@@ -0,0 +1,59 @@
1
+ require 'aloe/account'
2
+ require 'aloe/transaction'
3
+ require 'active_record'
4
+ require 'money'
5
+
6
+ module Aloe
7
+ class Entry < ActiveRecord::Base
8
+
9
+ # Scopes
10
+
11
+ default_scope do
12
+ order('aloe_entries.created_at DESC')
13
+ end
14
+
15
+ scope :withdrawals, -> { where('amount < 0') }
16
+ scope :deposits, -> { where('amount > 0') }
17
+
18
+ # Associations
19
+
20
+ belongs_to :account, class_name: "Aloe::Account"
21
+ has_one :credit_transaction, class_name: "Aloe::Transaction",
22
+ foreign_key: "credit_entry_id"
23
+ has_one :debit_transaction, class_name: "Aloe::Transaction",
24
+ foreign_key: "debit_entry_id"
25
+
26
+ # Instance methods
27
+
28
+ delegate :currency, to: :account
29
+
30
+ # Return the amount of entry
31
+ #
32
+ # @return [Money] The amount
33
+ def amount
34
+ Money.new read_attribute(:amount), currency
35
+ end
36
+ #
37
+ # Return the related transaction.
38
+ #
39
+ # @return [Aloe::Transaction]
40
+ def transaction
41
+ credit_transaction || debit_transaction
42
+ end
43
+
44
+ # Is the entry deposit of funds?
45
+ #
46
+ # @return [true, false]
47
+ def deposit?
48
+ !withdrawal?
49
+ end
50
+
51
+ # Is the entry withdrawal of funds?
52
+ #
53
+ # @return [true, false]
54
+ def withdrawal?
55
+ amount.negative?
56
+ end
57
+
58
+ end
59
+ end
@@ -0,0 +1,11 @@
1
+ module Aloe
2
+ class InoperableAccountError < StandardError
3
+ def initialize(account)
4
+ @account = account
5
+ end
6
+
7
+ def to_s
8
+ "Account #{@account} is inoperable! (account state: #{@account.state})"
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,14 @@
1
+ module Aloe
2
+ class InsufficientBalanceError < StandardError
3
+
4
+ def initialize(account, expected_balance)
5
+ @account, @expected_balance = account, expected_balance
6
+ end
7
+
8
+ def to_s
9
+ "Account #{@account} has not enough balance" +
10
+ " - required at least #{@expected_balance}!"
11
+ end
12
+
13
+ end
14
+ end
@@ -0,0 +1,3 @@
1
+ module Aloe
2
+ class InvalidAmountError < StandardError; end
3
+ end
@@ -0,0 +1,16 @@
1
+ module Aloe
2
+ class InvalidCurrencyError < StandardError
3
+
4
+ def initialize(credit_account, debit_account, currency)
5
+ @credit_account = credit_account
6
+ @debit_account = debit_account
7
+ @currency = currency
8
+ end
9
+
10
+ def to_s
11
+ "Different currencies on accounts #{@credit_account}, #{@debit_account}" +
12
+ " - expected #{@currency}."
13
+ end
14
+
15
+ end
16
+ end
@@ -0,0 +1,83 @@
1
+ require 'aloe/account'
2
+ require 'aloe/transaction'
3
+ require 'aloe/ledger_entry'
4
+ require 'money'
5
+
6
+ module Aloe
7
+ # Ledger is the façade interface to the account. When manipulating accounts
8
+ # (eg. creating transactions between accounts) you should use the Ledger and
9
+ # not the underlying models.
10
+ module Ledger
11
+
12
+ extend self
13
+
14
+ attr_writer :account_scope
15
+
16
+ # Returns an account for given owner.
17
+ #
18
+ # @param [Object, Symbol] owner Account owner or account name
19
+ # @param [String, Symbol] currency Currency symbol
20
+ # @return [Aloe::Account, nil] Account which belongs to given object
21
+ # or nil
22
+ def find_account(owner, currency = default_currency)
23
+ scope_for_owner(owner).currency(currency.to_s).first
24
+ end
25
+
26
+ # Returns accounts for a given owner.
27
+ #
28
+ # @param [Object, Symbol] owner Account owner or account name
29
+ # @param [String, Symbol] currency Currency symbol
30
+ # @return [Aloe::Account, nil] Array of accounts which belongs to
31
+ # given object
32
+ def find_accounts(owner, currency = nil)
33
+ scope = scope_for_owner(owner)
34
+ currency ? scope.currency(currency) : scope
35
+ end
36
+
37
+ # Creates entry in the ledger.
38
+ #
39
+ # Creates entries in both credit and debit account and linking
40
+ # transaction between those two entries. Credit and debit accounts
41
+ # and given amount have to be in the same currency otherwise an
42
+ # exception is raised.
43
+ #
44
+ # @param [Money] amount The amount of money
45
+ # @param [Hash] options Options
46
+ # @option options [Aloe::Account] :from Account which to debit
47
+ # @option options [Aloe::Account] :to Account which to credit
48
+ # @option options [Fixnum] :type Type of transaction
49
+ # @return [Aloe::Transaction]
50
+ def create_entry(amount, options)
51
+ Aloe::LedgerEntry.new(amount, options).create!
52
+ end
53
+
54
+ # Return the default currency that gets used when no currency is specified.
55
+ #
56
+ # @return [String]
57
+ def default_currency
58
+ Money.default_currency.to_s
59
+ end
60
+
61
+ protected
62
+
63
+ # Return the scope for account with given owner.
64
+ #
65
+ # @param [Object]
66
+ # @return [ActiveRecord::Relation]
67
+ def scope_for_owner(owner)
68
+ if owner.class.respond_to?(:model_name)
69
+ account_scope.owner(owner)
70
+ else
71
+ account_scope.where(name: owner.to_s.titleize)
72
+ end
73
+ end
74
+
75
+ # Return the scope for Accounts repository.
76
+ #
77
+ # @return [ActiveRecord::Relation]
78
+ def account_scope
79
+ @account_scope ||= Aloe::Account.unscoped
80
+ end
81
+
82
+ end
83
+ end