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,83 @@
1
+ require 'aloe/invalid_currency_error'
2
+ require 'aloe/invalid_amount_error'
3
+ require 'aloe/insufficient_balance_error'
4
+ require 'aloe/inoperable_account_error'
5
+
6
+ module Aloe
7
+ # LedgerEntry is use-case class that encompasses the process of moving funds
8
+ # between accounts.
9
+ class LedgerEntry < Struct.new(:amount, :options)
10
+
11
+ def initialize(*args)
12
+ super
13
+ raise Aloe::InvalidAmountError if amount.zero?
14
+ unless same_currencies?
15
+ raise Aloe::InvalidCurrencyError.new(debit_account, credit_account,
16
+ amount.currency)
17
+ end
18
+ unless debit_account.open?
19
+ raise Aloe::InoperableAccountError.new(debit_account)
20
+ end
21
+ unless credit_account.open?
22
+ raise Aloe::InoperableAccountError.new(credit_account)
23
+ end
24
+ end
25
+
26
+ def create!
27
+ ActiveRecord::Base.transaction do
28
+ debit_entry = debit_account.create_entry(-amount.cents)
29
+ credit_entry = credit_account.create_entry(amount.cents)
30
+ attributes = { credit_entry: credit_entry,
31
+ debit_entry: debit_entry,
32
+ category: category }.merge options
33
+ Aloe::Transaction::create! attributes
34
+ end
35
+ end
36
+
37
+ protected
38
+
39
+ def category
40
+ @category ||= options.delete(:type)
41
+ end
42
+
43
+ # @return [Aloe::Account]
44
+ def credit_account
45
+ @credit_account ||= may_find_account(options.delete(:to), currency)
46
+ end
47
+
48
+ # @return [String]
49
+ def currency
50
+ @currency ||= amount.currency
51
+ end
52
+
53
+ # @return [Aloe::Account]
54
+ def debit_account
55
+ @debit_account ||= may_find_account(options.delete(:from), currency)
56
+ end
57
+
58
+ # @return [Class]
59
+ def ledger
60
+ Aloe::Ledger
61
+ end
62
+
63
+ # @param [Object, Aloe::Aloe] account_or_owner
64
+ # @param [String, Symbol] currency
65
+ # @return [Aloe::Account]
66
+ def may_find_account(account_or_owner, currency)
67
+ if account_or_owner.respond_to?(:create_entry)
68
+ account_or_owner
69
+ elsif account_or_owner
70
+ ledger.find_account account_or_owner, currency
71
+ end
72
+ end
73
+
74
+ # Are the two accounts of the matching currency as the given amount?
75
+ #
76
+ # @return [true, false]
77
+ def same_currencies?
78
+ debit_account.currency?(amount.currency) &&
79
+ credit_account.currency?(amount.currency)
80
+ end
81
+
82
+ end
83
+ end
@@ -0,0 +1,32 @@
1
+ module Aloe
2
+ module Reports
3
+ class AccountHistory
4
+
5
+ attr_reader :account
6
+
7
+ def initialize(account)
8
+ @account = account
9
+ end
10
+
11
+ def header
12
+ ['Transaction', 'Credit', 'Debit', 'Amount', 'Time']
13
+ end
14
+
15
+ def body
16
+ account.entries.map do |e|
17
+ tr = e.transaction
18
+ [tr.category,
19
+ tr.credit_entry.account.to_s,
20
+ tr.debit_entry.account.to_s,
21
+ e.amount.format,
22
+ e.created_at.to_s]
23
+ end
24
+ end
25
+
26
+ def footer
27
+ ['', '', '', account.balance.format, '']
28
+ end
29
+
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,39 @@
1
+ module Aloe
2
+ module Reports
3
+ class AccountsList
4
+
5
+ attr_reader :currency
6
+
7
+ def initialize(currency = Money.default_currency.to_s)
8
+ @currency = currency
9
+ end
10
+
11
+ def header
12
+ ['Type', 'Id', 'Name', 'Owner', 'Currency', 'Balance']
13
+ end
14
+
15
+ def body
16
+ accounts.map do |a|
17
+ type = a.name? ? 'System' : 'Entity'
18
+ [type, a.id, a.name, a.owner_type_and_id, a.currency, a.balance.format]
19
+ end
20
+ end
21
+
22
+ def footer
23
+ ['', '', '', '', '', trial_balance.format]
24
+ end
25
+
26
+ protected
27
+
28
+ def accounts
29
+ @accounts ||= Aloe::Account.currency(@currency).all
30
+ end
31
+
32
+ def trial_balance
33
+ amount = Aloe::Account.trial_balance(@currency)
34
+ Money.new(amount, @currency)
35
+ end
36
+
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,80 @@
1
+ require 'aloe/entry'
2
+ require 'aloe/transaction_rollback'
3
+ require 'active_record'
4
+ require 'uuid'
5
+
6
+ module Aloe
7
+ class Transaction < ActiveRecord::Base
8
+
9
+ # Associations
10
+
11
+ belongs_to :credit_entry, class_name: "Aloe::Entry"
12
+ has_one :credit_account, through: :credit_entry, source: :account
13
+ belongs_to :debit_entry, class_name: "Aloe::Entry"
14
+ has_one :debit_account, through: :debit_entry, source: :account
15
+ belongs_to :adjustment_transaction, class_name: "Transaction"
16
+ has_one :adjusted_transaction, class_name: "Transaction",
17
+ foreign_key: "adjustment_transaction_id"
18
+
19
+ # Validations
20
+
21
+ validates_presence_of :credit_entry
22
+ validates_presence_of :debit_entry
23
+ validates_presence_of :category
24
+
25
+ # Callbacks
26
+
27
+ before_create :assign_uuid
28
+
29
+ # Instance methods
30
+
31
+ serialize :details
32
+
33
+ # Return transaction details hash.
34
+ #
35
+ # @return [Hash]
36
+ def details
37
+ attributes["details"] ||= {}
38
+ end
39
+
40
+ # Return entries of transaction.
41
+ #
42
+ # @return [Array<Aloe::Entry>]
43
+ def entries
44
+ [debit_entry, credit_entry]
45
+ end
46
+
47
+ # Return the type of transaction.
48
+ #
49
+ # Type of transaction is stored in +category+ attribute
50
+ # internally because AR uses +type+ for STI.
51
+ #
52
+ # @return [Fixnum]
53
+ def type
54
+ category
55
+ end
56
+
57
+ # Returns the amount of transaction.
58
+ #
59
+ # @return [Money]
60
+ def amount
61
+ credit_entry.amount.abs
62
+ end
63
+
64
+ # Rollback transaction by creating balancing entries.
65
+ def rollback
66
+ Aloe::TransactionRollback.new(self).rollback!
67
+ end
68
+
69
+ def number
70
+ uuid.first(8)
71
+ end
72
+
73
+ protected
74
+
75
+ def assign_uuid
76
+ write_attribute :uuid, UUID.new.generate(:compact)
77
+ end
78
+
79
+ end
80
+ end
@@ -0,0 +1,34 @@
1
+ module Aloe
2
+ # Use case class for rolling back a transaction.
3
+ class TransactionRollback < Struct.new(:transaction)
4
+
5
+ def rollback!
6
+ ActiveRecord::Base.transaction do
7
+ e1 = credit_entry.account.create_entry debit_entry.amount.cents
8
+ e2 = debit_entry.account.create_entry credit_entry.amount.cents
9
+ rollback = Aloe::Transaction.create! credit_entry: e2,
10
+ debit_entry: e1,
11
+ category: Aloe::ROLLBACK_TRANSACTION
12
+ transaction.update_attribute :adjustment_transaction, rollback
13
+ end
14
+ end
15
+
16
+ private
17
+
18
+ # Return credit entry of the transaction.
19
+ #
20
+ # @return [Aloe::Entry]
21
+ def credit_entry
22
+ transaction.credit_entry
23
+ end
24
+
25
+ # Return debit entry of the transaction.
26
+ #
27
+ # @return [Aloe::Entry]
28
+ def debit_entry
29
+ transaction.debit_entry
30
+ end
31
+
32
+ end
33
+ end
34
+
@@ -0,0 +1,3 @@
1
+ module Aloe
2
+ VERSION = '0.1.0'
3
+ end
@@ -0,0 +1,17 @@
1
+ require 'rails/generators'
2
+ require 'rails/generators/migration'
3
+
4
+ class AloeGenerator < Rails::Generators::Base
5
+
6
+ include Rails::Generators::Migration
7
+ source_root File.expand_path('../templates', __FILE__)
8
+
9
+ def self.next_migration_number(path)
10
+ Time.now.utc.strftime("%Y%m%d%H%M%S")
11
+ end
12
+
13
+ def create_migration_file
14
+ migration_template 'migration.rb', 'db/migrate/create_aloe_tables.rb'
15
+ end
16
+ end
17
+
@@ -0,0 +1,44 @@
1
+ class CreateAloeTables < ActiveRecord::Migration
2
+
3
+ def change
4
+ create_table :aloe_accounts do |t|
5
+ t.string :name
6
+ t.string :currency, limit: 3
7
+ t.string :state
8
+ t.text :configuration
9
+ t.column :balance, :bigint, default: 0
10
+ t.references :owner, polymorphic: true
11
+ t.timestamps
12
+ end
13
+
14
+ add_index :aloe_accounts, [:owner_id, :owner_type]
15
+ add_index :aloe_accounts, :status
16
+
17
+ create_table :aloe_entries do |t|
18
+ t.column :amount, :bigint
19
+ t.references :account
20
+ t.timestamps
21
+ end
22
+
23
+ add_index :aloe_entries, :account_id
24
+
25
+ create_table :aloe_transactions do |t|
26
+ t.string :uuid
27
+ t.string :category
28
+ t.string :code
29
+ t.text :description
30
+ t.text :details
31
+ t.references :credit_entry
32
+ t.references :debit_entry
33
+ t.references :adjustment_transaction
34
+ t.timestamps
35
+ end
36
+
37
+ add_index :aloe_transactions, :uuid
38
+ add_index :aloe_transactions, :credit_entry_id
39
+ add_index :aloe_transactions, :debit_entry_id
40
+ add_index :aloe_transactions, :adjustment_transaction_id
41
+ add_index :aloe_transactions, :category
42
+ end
43
+
44
+ end
@@ -0,0 +1,24 @@
1
+ require 'terminal-table'
2
+
3
+ task :aloe do
4
+
5
+ desc "Show overview of accounts and their balances"
6
+ task :list_accounts => :environment do
7
+ currency = ENV["CURRENCY"].presence || Money.default_currency.to_s
8
+ report = Aloe::Reports::AccountsList.new currency
9
+ rows = report.body + [:separator] + [report.footer]
10
+ table = Terminal::Table.new headings: report.header, rows: rows
11
+ table.align_column(5, :right)
12
+ puts table
13
+ end
14
+
15
+ desc "Show history of account"
16
+ task :account_history => :environment do
17
+ account = Aloe::Account.find ENV["ACCOUNT_ID"]
18
+ report = Aloe::Reports::AccountHistory.new(account)
19
+ rows = report.body + [:separator] + [report.footer]
20
+ table = Terminal::Table.new headings: report.header, rows: rows
21
+ puts table
22
+ end
23
+
24
+ end
@@ -0,0 +1,21 @@
1
+ # encoding: utf-8
2
+
3
+ require "spec_helper"
4
+
5
+ describe Aloe::AccountConfiguration do
6
+ describe "#allow_negative_balance" do
7
+ it "is true by default" do
8
+ subject.allow_negative_balance.should be_true
9
+ end
10
+
11
+ it "return true if underlying variable is true" do
12
+ subject.allow_negative_balance = true
13
+ subject.allow_negative_balance.should be_true
14
+ end
15
+
16
+ it "return false if underlying variable is false" do
17
+ subject.allow_negative_balance = false
18
+ subject.allow_negative_balance.should be_false
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,144 @@
1
+ # encoding: utf-8
2
+
3
+ require 'spec_helper'
4
+ require 'timecop'
5
+
6
+ describe 'Account integration spec', integration: true do
7
+ let(:owner) { User.create! }
8
+ let(:owner2) { User.create! }
9
+
10
+ describe 'default scope' do
11
+ before do
12
+ closed = create_account owner2, :GBP
13
+ closed.close!
14
+ @account = create_account owner, :GBP
15
+ end
16
+
17
+ it 'excludes closed account' do
18
+ Aloe::Account.all.should eq [@account]
19
+ end
20
+ end
21
+
22
+ describe '#closed scope' do
23
+ before do
24
+ @closed = create_account owner2, :GBP
25
+ @closed.close!
26
+ account = create_account owner, :GBP
27
+ end
28
+
29
+ it 'includes closed accounts only' do
30
+ Aloe::Account.closed.should eq [@closed]
31
+ end
32
+ end
33
+
34
+ describe 'default account state' do
35
+ let(:account) { Aloe::Account.new }
36
+
37
+ it 'is open' do
38
+ account.state.should eq 'open'
39
+ account.should be_open
40
+ end
41
+ end
42
+
43
+ describe 'closing account' do
44
+ context 'an account with zero balance' do
45
+ let(:account) { create_account owner, :GBP }
46
+
47
+ it 'can be safely closed' do
48
+ account.close!
49
+ account.should be_closed
50
+ end
51
+ end
52
+
53
+ context 'an account with non-zero balance' do
54
+ let(:account) { create_account owner, :GBP }
55
+
56
+ before do
57
+ account.update_column :balance, 123
58
+ end
59
+
60
+ it 'cannot be closed' do
61
+ lambda do
62
+ account.close!
63
+ end.should raise_error(StateMachine::InvalidTransition)
64
+ account.should_not be_closed
65
+ account.should be_open
66
+ end
67
+ end
68
+ end
69
+
70
+ describe 'opening closed account' do
71
+ let(:account) { create_account owner, :GBP }
72
+
73
+ before do
74
+ account.close!
75
+ end
76
+
77
+ it 'can be safely closed' do
78
+ account.reopen!
79
+ account.should be_open
80
+ end
81
+ end
82
+
83
+ describe 'suspending open account' do
84
+ let(:account) { create_account owner, :GBP }
85
+
86
+ it 'can be safely closed' do
87
+ account.suspend!
88
+ account.should be_suspended
89
+ end
90
+ end
91
+
92
+ describe 'unsuspending suspended account' do
93
+ let(:account) { create_account owner, :GBP }
94
+
95
+ before do
96
+ account.suspend!
97
+ end
98
+
99
+ it 'can be safely closed' do
100
+ account.unsuspend!
101
+ account.should be_open
102
+ end
103
+ end
104
+
105
+ describe '.turnover' do
106
+ let(:account) { create_account owner, :GBP }
107
+ let(:amounts1) { [765, 927, 123, -1239, 212, -232] }
108
+ let(:amounts2) { [23, 123, 145, 7864, -7231] }
109
+
110
+ before do
111
+ account.update_column :created_at, 30.days.ago
112
+ Timecop.travel(30.days.ago) do
113
+ amounts1.each { |amount| account.create_entry(amount) }
114
+ end
115
+ Timecop.travel(20.days.ago) do
116
+ amounts2.each { |amount| account.create_entry(amount) }
117
+ end
118
+ end
119
+
120
+ it 'returns turnover in given period' do
121
+ account.turnover(21.days.ago..Time.now).should eq Money.new(amounts2.sum, :GBP)
122
+ end
123
+ end
124
+
125
+ describe '.balance_at' do
126
+ let(:account) { create_account owner, :GBP }
127
+ let(:amounts1) { [765, 927, 123, -1239, 212, -232] }
128
+ let(:amounts2) { [23, 123, 145, 7864, -7231] }
129
+
130
+ before do
131
+ account.update_column :created_at, 30.days.ago
132
+ Timecop.travel(30.days.ago) do
133
+ amounts1.each { |amount| account.create_entry(amount) }
134
+ end
135
+ Timecop.travel(20.days.ago) do
136
+ amounts2.each { |amount| account.create_entry(amount) }
137
+ end
138
+ end
139
+
140
+ it 'returns turnover in given period' do
141
+ account.balance_at(29.days.ago).should eq Money.new(amounts1.sum, :GBP)
142
+ end
143
+ end
144
+ end