aloe 0.1.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 (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