├── .yardopts ├── Gemfile ├── spec ├── support │ ├── money.rb │ ├── rspec_its.rb │ ├── timecop.rb │ ├── reporting_configuration.rb │ ├── logging.rb │ ├── database.example.yml │ ├── database.ci.yml │ ├── database.rb │ ├── performance_helper.rb │ ├── factories.rb │ ├── accounts.rb │ ├── double_entry_spec_helper.rb │ └── schema.rb ├── spec_support.rb ├── generators │ └── double_entry │ │ └── reporting │ │ └── install │ │ └── install_generator_spec.rb ├── double_entry │ ├── reporting │ │ ├── line_metadata_filter_spec.rb │ │ ├── line_aggregate_spec.rb │ │ ├── time_range_spec.rb │ │ ├── week_range_spec.rb │ │ ├── line_aggregate_filter_spec.rb │ │ ├── month_range_spec.rb │ │ ├── time_range_array_spec.rb │ │ ├── aggregate_array_spec.rb │ │ └── aggregate_spec.rb │ └── reporting_spec.rb ├── performance │ └── reporting │ │ └── aggregate_performance_spec.rb └── spec_helper.rb ├── .rspec ├── lib ├── double_entry │ ├── reporting │ │ ├── version.rb │ │ ├── line_aggregate.rb │ │ ├── year_range.rb │ │ ├── line_metadata_filter.rb │ │ ├── day_range.rb │ │ ├── hour_range.rb │ │ ├── time_range_array.rb │ │ ├── time_range.rb │ │ ├── line_aggregate_filter.rb │ │ ├── month_range.rb │ │ ├── week_range.rb │ │ ├── aggregate_array.rb │ │ └── aggregate.rb │ └── reporting.rb └── generators │ └── double_entry │ └── reporting │ └── install │ ├── install_generator.rb │ └── templates │ └── migration.rb ├── Rakefile ├── script └── setup.sh ├── gemfiles ├── rails_6.1.gemfile ├── rails_7.0.gemfile └── rails_7.1.gemfile ├── .gitignore ├── LICENSE.md ├── .github └── workflows │ └── ci.yml ├── README.md ├── double_entry-reporting.gemspec └── CHANGELOG.md /.yardopts: -------------------------------------------------------------------------------- 1 | --markup markdown 2 | - LICENSE.md 3 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | gemspec 4 | -------------------------------------------------------------------------------- /spec/support/money.rb: -------------------------------------------------------------------------------- 1 | Money.locale_backend = :i18n 2 | -------------------------------------------------------------------------------- /spec/support/rspec_its.rb: -------------------------------------------------------------------------------- 1 | require 'rspec/its' 2 | 3 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --require double_entry/reporting 2 | --require spec_helper 3 | --require spec_support 4 | -------------------------------------------------------------------------------- /spec/spec_support.rb: -------------------------------------------------------------------------------- 1 | Dir.glob(File.join(__dir__, 'support/**/*.rb')).sort.each { |f| require f } 2 | -------------------------------------------------------------------------------- /lib/double_entry/reporting/version.rb: -------------------------------------------------------------------------------- 1 | module DoubleEntry 2 | module Reporting 3 | VERSION = '0.1.0'.freeze 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require 'rspec/core/rake_task' 2 | require 'bundler/gem_tasks' 3 | 4 | RSpec::Core::RakeTask.new(:spec) 5 | task :default => [:spec] 6 | -------------------------------------------------------------------------------- /spec/support/timecop.rb: -------------------------------------------------------------------------------- 1 | require 'timecop' 2 | 3 | RSpec.configure do |config| 4 | config.after(:example) do 5 | Timecop.return 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /spec/support/reporting_configuration.rb: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | RSpec.configure do |config| 3 | config.before do 4 | DoubleEntry::Reporting.instance_variable_set(:@configuration, nil) 5 | end 6 | end 7 | -------------------------------------------------------------------------------- /script/setup.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | echo "This command will setup your local dev environment, including" 4 | echo " * bundle install" 5 | echo 6 | 7 | echo "Bundling..." 8 | bundle install --binstubs bin --path .bundle 9 | -------------------------------------------------------------------------------- /gemfiles/rails_6.1.gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | gemspec path: '../' 3 | 4 | gem 'activerecord', '~> 6.1.0' 5 | 6 | # Rails imposed mysql2 version contraints 7 | # https://github.com/rails/rails/blob/6-1-stable/activerecord/lib/active_record/connection_adapters/mysql2_adapter.rb#L6 8 | gem 'mysql2', '~> 0.5' 9 | -------------------------------------------------------------------------------- /gemfiles/rails_7.0.gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | gemspec path: '../' 3 | 4 | gem 'activerecord', '~> 7.0.0' 5 | 6 | # Rails imposed mysql2 version contraints 7 | # https://github.com/rails/rails/blob/7-0-stable/activerecord/lib/active_record/connection_adapters/mysql2_adapter.rb#L6 8 | gem 'mysql2', '~> 0.5' 9 | -------------------------------------------------------------------------------- /gemfiles/rails_7.1.gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | gemspec path: '../' 3 | 4 | gem 'activerecord', '~> 7.1.0' 5 | 6 | # Rails imposed mysql2 version contraints 7 | # https://github.com/rails/rails/blob/7-1-stable/activerecord/lib/active_record/connection_adapters/mysql2_adapter.rb#L6 8 | gem 'mysql2', '~> 0.5' 9 | -------------------------------------------------------------------------------- /spec/support/logging.rb: -------------------------------------------------------------------------------- 1 | require 'active_record' 2 | require 'active_support/logger' 3 | 4 | RSpec.configure do |config| 5 | config.before(:suite) do 6 | log_file = File.expand_path('../../../log/test.log', __FILE__) 7 | 8 | FileUtils.mkdir_p(File.dirname(log_file)) 9 | FileUtils.rm(log_file, force: true) 10 | ActiveRecord::Base.logger = ActiveSupport::Logger.new(log_file) 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /spec/support/database.example.yml: -------------------------------------------------------------------------------- 1 | postgres: 2 | host: localhost 3 | adapter: postgresql 4 | encoding: unicode 5 | database: double_entry_reporting_test 6 | pool: 100 7 | username: postgres 8 | password: 9 | min_messages: warning 10 | mysql: 11 | adapter: mysql2 12 | encoding: utf8 13 | database: double_entry_reporting_test 14 | pool: 100 15 | username: root 16 | password: 17 | sqlite: 18 | adapter: sqlite3 19 | encoding: utf8 20 | database: tmp/double_entry_reporting_test.sqlite3 21 | pool: 100 22 | -------------------------------------------------------------------------------- /spec/support/database.ci.yml: -------------------------------------------------------------------------------- 1 | mysql: 2 | adapter: mysql2 3 | username: mysql 4 | password: password 5 | database: double_entry_test 6 | pool: 100 7 | timeout: 5000 8 | host: 127.0.0.1 9 | 10 | postgres: 11 | adapter: postgresql 12 | username: postgres 13 | password: password 14 | database: double_entry_test 15 | min_messages: ERROR 16 | pool: 100 17 | timeout: 5000 18 | host: localhost 19 | 20 | sqlite: 21 | adapter: sqlite3 22 | encoding: utf8 23 | database: tmp/double_entry_test.sqlite3 24 | pool: 100 25 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.gem 2 | *.rbc 3 | /.config 4 | /coverage/ 5 | /InstalledFiles 6 | /pkg/ 7 | /spec/reports/ 8 | /test/tmp/ 9 | /test/version_tmp/ 10 | /tmp/ 11 | 12 | ## Performance profiling 13 | /profiles/ 14 | 15 | ## Documentation cache and generated files: 16 | /.yardoc/ 17 | /_yardoc/ 18 | /doc/ 19 | /rdoc/ 20 | 21 | ## Environment normalisation: 22 | /.bundle/ 23 | /lib/bundler/man/ 24 | /Gemfile.lock 25 | /.ruby-version 26 | /.ruby-gemset 27 | 28 | # DoubleEntry specific 29 | /bin/ 30 | /log/ 31 | /spec/examples.txt 32 | /spec/reports/ 33 | /spec/support/database.yml 34 | -------------------------------------------------------------------------------- /lib/double_entry/reporting/line_aggregate.rb: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | module DoubleEntry 3 | module Reporting 4 | class LineAggregate < ActiveRecord::Base 5 | def self.aggregate(function:, account:, partner_account:, code:, range:, named_scopes:) 6 | collection_filter = LineAggregateFilter.new(account: account, partner_account: partner_account, 7 | code: code, range: range, filter_criteria: named_scopes) 8 | collection = collection_filter.filter 9 | collection.send(function, :amount) 10 | end 11 | 12 | def key 13 | "#{year}:#{month}:#{week}:#{day}:#{hour}" 14 | end 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /spec/support/database.rb: -------------------------------------------------------------------------------- 1 | require 'active_record' 2 | require 'database_cleaner' 3 | 4 | FileUtils.mkdir_p 'tmp' 5 | 6 | db_engine = ENV['DB'] || 'mysql' 7 | database_config_file = File.expand_path('../database.yml', __FILE__) 8 | 9 | raise <<-MSG.strip_heredoc unless File.exist?(database_config_file) 10 | Please configure your spec/support/database.yml file. 11 | See spec/support/database.example.yml' 12 | MSG 13 | 14 | ActiveRecord::Base.establish_connection(YAML.load_file(database_config_file)[db_engine]) 15 | 16 | RSpec.configure do |config| 17 | config.before(:suite) do 18 | DatabaseCleaner.strategy = :truncation 19 | end 20 | 21 | config.before(:example) do 22 | DatabaseCleaner.clean 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /spec/support/performance_helper.rb: -------------------------------------------------------------------------------- 1 | module PerformanceHelper 2 | require 'ruby-prof' 3 | 4 | def start_profiling(measure_mode = RubyProf::PROCESS_TIME) 5 | RubyProf.measure_mode = measure_mode 6 | RubyProf.start 7 | end 8 | 9 | def stop_profiling(profile_name = nil) 10 | result = RubyProf.stop 11 | puts "#{profile_name} Time: #{format('%#.3g', total_time(result))}s" 12 | unless ENV.fetch('CI', false) 13 | if profile_name 14 | outdir = './profiles' 15 | Dir.mkdir(outdir) unless Dir.exist?(outdir) 16 | printer = RubyProf::MultiPrinter.new(result) 17 | printer.print(:path => outdir, :profile => profile_name) 18 | end 19 | end 20 | result 21 | end 22 | 23 | def total_time(result) 24 | result.threads.inject(0) { |time, thread| time + thread.total_time } 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /lib/double_entry/reporting/year_range.rb: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | module DoubleEntry 3 | module Reporting 4 | class YearRange < TimeRange 5 | attr_reader :year 6 | 7 | def initialize(options) 8 | super options 9 | 10 | year_start = Time.local(@year, 1, 1) 11 | @start = year_start 12 | @finish = year_start.end_of_year 13 | end 14 | 15 | def self.current 16 | new(:year => Time.now.year) 17 | end 18 | 19 | def self.from_time(time) 20 | new(:year => time.year) 21 | end 22 | 23 | def ==(other) 24 | year == other.year 25 | end 26 | 27 | def previous 28 | YearRange.new(:year => year - 1) 29 | end 30 | 31 | def next 32 | YearRange.new(:year => year + 1) 33 | end 34 | 35 | def to_s 36 | year.to_s 37 | end 38 | end 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /spec/generators/double_entry/reporting/install/install_generator_spec.rb: -------------------------------------------------------------------------------- 1 | require 'action_controller' 2 | require 'generator_spec/test_case' 3 | require 'generators/double_entry/reporting/install/install_generator' 4 | 5 | RSpec.describe DoubleEntry::Reporting::Generators::InstallGenerator do 6 | include GeneratorSpec::TestCase 7 | 8 | destination File.expand_path('../../../../../../tmp/generators', __FILE__) 9 | 10 | before do 11 | prepare_destination 12 | run_generator 13 | end 14 | 15 | it 'generates the expected migrations' do 16 | expect(destination_root).to have_structure { 17 | directory 'db' do 18 | directory 'migrate' do 19 | migration 'create_double_entry_reporting_tables' do 20 | contains 'class CreateDoubleEntryReportingTables' 21 | contains 'create_table "double_entry_line_aggregates"' 22 | end 23 | end 24 | end 25 | } 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /lib/generators/double_entry/reporting/install/install_generator.rb: -------------------------------------------------------------------------------- 1 | require 'rails/generators' 2 | require 'rails/generators/migration' 3 | require 'rails/generators/active_record' 4 | 5 | module DoubleEntry 6 | module Reporting 7 | module Generators 8 | class InstallGenerator < Rails::Generators::Base 9 | include Rails::Generators::Migration 10 | 11 | source_root File.expand_path('../templates', __FILE__) 12 | 13 | def self.next_migration_number(path) 14 | ActiveRecord::Generators::Base.next_migration_number(path) 15 | end 16 | 17 | def copy_migrations 18 | migration_template 'migration.rb', 'db/migrate/create_double_entry_reporting_tables.rb', migration_version: migration_version 19 | end 20 | 21 | def migration_version 22 | if ActiveRecord.version.version > '5' 23 | "[#{ActiveRecord::VERSION::MAJOR}.#{ActiveRecord::VERSION::MINOR}]" 24 | end 25 | end 26 | end 27 | end 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /lib/double_entry/reporting/line_metadata_filter.rb: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | module DoubleEntry 3 | module Reporting 4 | class LineMetadataFilter 5 | 6 | def self.filter(collection:, metadata:) 7 | table_alias_index = 0 8 | 9 | metadata.reduce(collection) do |filtered_collection, (key, value)| 10 | table_alias = "m#{table_alias_index}" 11 | table_alias_index += 1 12 | 13 | filtered_collection. 14 | joins("INNER JOIN #{line_metadata_table} as #{table_alias} ON #{table_alias}.line_id = #{lines_table}.id"). 15 | where("#{table_alias}.key = ? AND #{table_alias}.value = ?", key, value) 16 | end 17 | end 18 | 19 | private 20 | 21 | def self.line_metadata_table 22 | DoubleEntry::LineMetadata.table_name 23 | end 24 | private_class_method :line_metadata_table 25 | 26 | def self.lines_table 27 | DoubleEntry::Line.table_name 28 | end 29 | private_class_method :lines_table 30 | 31 | end 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Copyright © 2014 Envato Pty Ltd 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in 11 | all copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | THE SOFTWARE. 20 | -------------------------------------------------------------------------------- /lib/generators/double_entry/reporting/install/templates/migration.rb: -------------------------------------------------------------------------------- 1 | class CreateDoubleEntryReportingTables < ActiveRecord::Migration<%= migration_version %> 2 | def self.up 3 | create_table "double_entry_line_aggregates", :force => true do |t| 4 | t.string "function", :limit => 15, :null => false 5 | t.string "account", :null => false 6 | t.string "code" 7 | t.string "partner_account" 8 | t.string "scope" 9 | t.integer "year" 10 | t.integer "month" 11 | t.integer "week" 12 | t.integer "day" 13 | t.integer "hour" 14 | t.bigint "amount", :null => false 15 | t.string "filter" 16 | t.string "range_type", :limit => 15, :null => false 17 | t.timestamps :null => false 18 | end 19 | add_index "double_entry_line_aggregates", ["function", "account", "code", "partner_account", "year", "month", "week", "day"], :name => "line_aggregate_idx" 20 | end 21 | 22 | def self.down 23 | drop_table "double_entry_line_aggregates" 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /lib/double_entry/reporting/day_range.rb: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | module DoubleEntry 3 | module Reporting 4 | class DayRange < TimeRange 5 | attr_reader :year, :week, :day 6 | 7 | def initialize(options) 8 | super options 9 | 10 | @week = options[:week] 11 | @day = options[:day] 12 | week_range = WeekRange.new(options) 13 | 14 | @start = week_range.start + (options[:day] - 1).days 15 | @finish = @start.end_of_day 16 | end 17 | 18 | def self.from_time(time) 19 | week_range = WeekRange.from_time(time) 20 | DayRange.new(:year => week_range.year, :week => week_range.week, :day => time.wday == 0 ? 7 : time.wday) 21 | end 22 | 23 | def previous 24 | DayRange.from_time(@start - 1.day) 25 | end 26 | 27 | def next 28 | DayRange.from_time(@start + 1.day) 29 | end 30 | 31 | def ==(other) 32 | week == other.week && 33 | year == other.year && 34 | day == other.day 35 | end 36 | 37 | def to_s 38 | start.strftime('%Y, %a %b %d') 39 | end 40 | end 41 | end 42 | end 43 | -------------------------------------------------------------------------------- /lib/double_entry/reporting/hour_range.rb: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | module DoubleEntry 3 | module Reporting 4 | class HourRange < TimeRange 5 | attr_reader :year, :week, :day, :hour 6 | 7 | def initialize(options) 8 | super options 9 | 10 | @week = options[:week] 11 | @day = options[:day] 12 | @hour = options[:hour] 13 | 14 | day_range = DayRange.new(options) 15 | 16 | @start = day_range.start + options[:hour].hours 17 | @finish = @start.end_of_hour 18 | end 19 | 20 | def self.from_time(time) 21 | day = DayRange.from_time(time) 22 | HourRange.new :year => day.year, :week => day.week, :day => day.day, :hour => time.hour 23 | end 24 | 25 | def previous 26 | HourRange.from_time(@start - 1.hour) 27 | end 28 | 29 | def next 30 | HourRange.from_time(@start + 1.hour) 31 | end 32 | 33 | def ==(other) 34 | week == other.week && 35 | year == other.year && 36 | day == other.day && 37 | hour == other.hour 38 | end 39 | 40 | def to_s 41 | "#{start.hour}:00:00 - #{start.hour}:59:59" 42 | end 43 | end 44 | end 45 | end 46 | -------------------------------------------------------------------------------- /spec/support/factories.rb: -------------------------------------------------------------------------------- 1 | require 'factory_bot' 2 | 3 | User = Class.new(ActiveRecord::Base) 4 | 5 | FactoryBot.define do 6 | factory :user do 7 | username { "user#{__id__}" } 8 | 9 | transient do 10 | savings_balance { false } 11 | checking_balance { false } 12 | bitcoin_balance { false } 13 | end 14 | 15 | after(:create) do |user, evaluator| 16 | if evaluator.savings_balance 17 | DoubleEntry.transfer( 18 | evaluator.savings_balance, 19 | from: DoubleEntry.account(:test, scope: user), 20 | to: DoubleEntry.account(:savings, scope: user), 21 | code: :bonus, 22 | ) 23 | end 24 | if evaluator.checking_balance 25 | DoubleEntry.transfer( 26 | evaluator.checking_balance, 27 | from: DoubleEntry.account(:test, scope: user), 28 | to: DoubleEntry.account(:checking, scope: user), 29 | code: :pay, 30 | ) 31 | end 32 | if evaluator.bitcoin_balance 33 | DoubleEntry.transfer( 34 | evaluator.bitcoin_balance, 35 | from: DoubleEntry.account(:btc_test, scope: user), 36 | to: DoubleEntry.account(:btc_savings, scope: user), 37 | code: :btc_test_transfer, 38 | ) 39 | end 40 | end 41 | end 42 | end 43 | 44 | RSpec.configure do |config| 45 | config.include FactoryBot::Syntax::Methods 46 | end 47 | -------------------------------------------------------------------------------- /spec/double_entry/reporting/line_metadata_filter_spec.rb: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | RSpec.describe DoubleEntry::Reporting::LineMetadataFilter do 3 | describe '.filter' do 4 | let(:collection) { DoubleEntry::Line } 5 | let(:metadata) { 6 | { 7 | :meme => 'business_cat', 8 | :genre => 'comedy', 9 | } 10 | } 11 | 12 | subject(:filter) { DoubleEntry::Reporting::LineMetadataFilter.filter(collection: collection, metadata: metadata) } 13 | 14 | before do 15 | allow(collection).to receive(:joins).and_return(collection) 16 | allow(collection).to receive(:where).and_return(collection) 17 | allow(DoubleEntry::LineMetadata).to receive(:table_name).and_return('double_entry_line_metadata') 18 | allow(DoubleEntry::Line).to receive(:table_name).and_return('double_entry_lines') 19 | filter 20 | end 21 | 22 | it 'queries for matches to the first key value pair' do 23 | expect(collection).to have_received(:joins). 24 | with('INNER JOIN double_entry_line_metadata as m0 ON m0.line_id = double_entry_lines.id') 25 | expect(collection).to have_received(:where). 26 | with('m0.key = ? AND m0.value = ?', :meme, 'business_cat') 27 | end 28 | 29 | it 'queries for matches to the second key value pair' do 30 | expect(collection).to have_received(:joins). 31 | with('INNER JOIN double_entry_line_metadata as m1 ON m1.line_id = double_entry_lines.id') 32 | expect(collection).to have_received(:where). 33 | with('m1.key = ? AND m1.value = ?', :genre, 'comedy') 34 | end 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /spec/support/accounts.rb: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | require_relative 'factories' 3 | DoubleEntry.configure do |config| 4 | # A set of accounts to test with 5 | config.define_accounts do |accounts| 6 | user_scope = lambda{|user| user.id} 7 | accounts.define(:identifier => :savings, :scope_identifier => user_scope, :positive_only => true) 8 | accounts.define(:identifier => :checking, :scope_identifier => user_scope, :positive_only => true) 9 | accounts.define(:identifier => :test, :scope_identifier => user_scope) 10 | accounts.define(:identifier => :btc_test, :scope_identifier => user_scope, :currency => 'BTC') 11 | accounts.define(:identifier => :btc_savings, :scope_identifier => user_scope, :currency => 'BTC') 12 | accounts.define(:identifier => :deposit_fees, :scope_identifier => user_scope, :positive_only => true) 13 | accounts.define(:identifier => :account_fees, :scope_identifier => user_scope, :positive_only => true) 14 | end 15 | 16 | # A set of allowed transfers between accounts 17 | config.define_transfers do |transfers| 18 | transfers.define(:from => :test, :to => :savings, :code => :bonus) 19 | transfers.define(:from => :test, :to => :checking, :code => :pay) 20 | transfers.define(:from => :savings, :to => :test, :code => :test_withdrawal) 21 | transfers.define(:from => :btc_test, :to => :btc_savings, :code => :btc_test_transfer) 22 | transfers.define(:from => :savings, :to => :deposit_fees, :code => :fee) 23 | transfers.define(:from => :savings, :to => :account_fees, :code => :fee) 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /spec/support/double_entry_spec_helper.rb: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | module DoubleEntrySpecHelper 3 | def lines_for_account(account) 4 | lines = DoubleEntry::Line.order(:id) 5 | lines = lines.where(:scope => account.scope_identity) if account.scoped? 6 | lines = lines.where(:account => account.identifier.to_s) 7 | lines 8 | end 9 | 10 | def perform_deposit(user, amount) 11 | DoubleEntry.transfer( 12 | Money.new(amount), 13 | :from => DoubleEntry.account(:test, :scope => user), 14 | :to => DoubleEntry.account(:savings, :scope => user), 15 | :code => :bonus, 16 | ) 17 | end 18 | 19 | def transfer_deposit_fee(user, amount) 20 | DoubleEntry.transfer( 21 | Money.new(amount), 22 | :from => DoubleEntry.account(:savings, :scope => user), 23 | :to => DoubleEntry.account(:deposit_fees, :scope => user), 24 | :code => :fee, 25 | ) 26 | end 27 | 28 | def transfer_account_fee(user, amount) 29 | DoubleEntry.transfer( 30 | Money.new(amount), 31 | :from => DoubleEntry.account(:savings, :scope => user), 32 | :to => DoubleEntry.account(:account_fees, :scope => user), 33 | :code => :fee, 34 | ) 35 | end 36 | 37 | def perform_btc_deposit(user, amount) 38 | DoubleEntry.transfer( 39 | Money.new(amount, :btc), 40 | :from => DoubleEntry.account(:btc_test, :scope => user), 41 | :to => DoubleEntry.account(:btc_savings, :scope => user), 42 | :code => :btc_test_transfer, 43 | ) 44 | end 45 | end 46 | 47 | RSpec.configure do |config| 48 | config.include DoubleEntrySpecHelper 49 | end 50 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: [push, pull_request] 3 | jobs: 4 | build: 5 | runs-on: ubuntu-latest 6 | strategy: 7 | matrix: 8 | ruby: ['3.3', '3.2', '3.1', '3.0'] 9 | rails: ['6.1', '7.0', '7.1'] 10 | db: [mysql, postgres, sqlite] 11 | env: 12 | BUNDLE_GEMFILE: ${{ github.workspace }}/gemfiles/rails_${{ matrix.rails }}.gemfile 13 | DB: ${{ matrix.db }} 14 | services: 15 | mysql: 16 | image: mysql:5.7 17 | env: 18 | MYSQL_DATABASE: double_entry_test 19 | MYSQL_USER: mysql 20 | MYSQL_PASSWORD: password 21 | MYSQL_ROOT_PASSWORD: root 22 | ports: 23 | - 3306:3306 24 | options: --health-cmd="mysqladmin ping" --health-interval=10s --health-timeout=5s --health-retries=3 25 | postgres: 26 | image: postgres 27 | env: 28 | POSTGRES_USER: postgres 29 | POSTGRES_PASSWORD: password 30 | POSTGRES_DB: double_entry_test 31 | # Set health checks to wait until postgres has started 32 | options: >- 33 | --health-cmd pg_isready 34 | --health-interval 10s 35 | --health-timeout 5s 36 | --health-retries 5 37 | ports: 38 | - 5432:5432 39 | steps: 40 | - name: Checkout 41 | uses: actions/checkout@v4 42 | - name: Set up Ruby 43 | uses: ruby/setup-ruby@v1 44 | with: 45 | ruby-version: ${{ matrix.ruby }} 46 | bundler-cache: true 47 | - run: cp spec/support/database.ci.yml spec/support/database.yml 48 | - run: bundle exec rspec 49 | -------------------------------------------------------------------------------- /spec/double_entry/reporting/line_aggregate_spec.rb: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | RSpec.describe DoubleEntry::Reporting::LineAggregate do 3 | describe '.table_name' do 4 | subject { DoubleEntry::Reporting::LineAggregate.table_name } 5 | it { should eq('double_entry_line_aggregates') } 6 | end 7 | 8 | describe '#aggregate' do 9 | let(:line_relation) { double } 10 | let(:filter) do 11 | instance_double(DoubleEntry::Reporting::LineAggregateFilter, :filter => line_relation) 12 | end 13 | 14 | let(:function) { :sum } 15 | let(:account) { double } 16 | let(:code) { double } 17 | let(:partner_account) { double } 18 | let(:named_scopes) { double } 19 | let(:range) { double } 20 | 21 | subject(:aggregate) do 22 | DoubleEntry::Reporting::LineAggregate.aggregate( 23 | function: function, 24 | account: account, 25 | partner_account: partner_account, 26 | code: code, 27 | range: range, 28 | named_scopes: named_scopes 29 | ) 30 | end 31 | 32 | before do 33 | allow(DoubleEntry::Reporting::LineAggregateFilter).to receive(:new).and_return(filter) 34 | allow(line_relation).to receive(:sum).with(:amount) 35 | aggregate 36 | end 37 | 38 | it 'applies the specified filters' do 39 | expect(DoubleEntry::Reporting::LineAggregateFilter).to have_received(:new). 40 | with(account: account, partner_account: partner_account, code: code, 41 | range: range, filter_criteria: named_scopes) 42 | expect(filter).to have_received(:filter) 43 | end 44 | 45 | it 'performs the aggregation on the filtered lines' do 46 | expect(line_relation).to have_received(:sum).with(:amount) 47 | end 48 | end 49 | end 50 | -------------------------------------------------------------------------------- /lib/double_entry/reporting/time_range_array.rb: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | module DoubleEntry 3 | module Reporting 4 | class TimeRangeArray 5 | attr_reader :type, :require_start 6 | alias_method :require_start?, :require_start 7 | 8 | def initialize(options = {}) 9 | @type = options[:type] 10 | @require_start = options[:require_start] 11 | end 12 | 13 | def make(start = nil, finish = nil) 14 | start = start_range(start) 15 | finish = finish_range(finish) 16 | [start].tap do |array| 17 | while start != finish 18 | start = start.next 19 | array << start 20 | end 21 | end 22 | end 23 | 24 | def start_range(start = nil) 25 | fail 'Must specify start of range' if start.blank? && require_start? 26 | start_time = start ? Time.parse(start) : Reporting.configuration.start_of_business 27 | type.from_time(start_time) 28 | end 29 | 30 | def finish_range(finish = nil) 31 | finish ? type.from_time(Time.parse(finish)) : type.current 32 | end 33 | 34 | FACTORIES = { 35 | 'hour' => new(:type => HourRange, :require_start => true), 36 | 'day' => new(:type => DayRange, :require_start => true), 37 | 'week' => new(:type => WeekRange, :require_start => true), 38 | 'month' => new(:type => MonthRange, :require_start => false), 39 | 'year' => new(:type => YearRange, :require_start => false), 40 | } 41 | 42 | def self.make(range_type, start = nil, finish = nil) 43 | factory = FACTORIES[range_type] 44 | fail ArgumentError, "Invalid range type '#{range_type}'" unless factory 45 | factory.make(start, finish) 46 | end 47 | end 48 | end 49 | end 50 | -------------------------------------------------------------------------------- /lib/double_entry/reporting/time_range.rb: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | module DoubleEntry 3 | module Reporting 4 | class TimeRange 5 | attr_reader :start, :finish 6 | attr_reader :year, :month, :week, :day, :hour, :range_type 7 | 8 | def self.make(options = {}) 9 | @options = options 10 | case 11 | when options[:year] && options[:week] && options[:day] && options[:hour] 12 | HourRange.new(options) 13 | when options[:year] && options[:week] && options[:day] 14 | DayRange.new(options) 15 | when options[:year] && options[:week] 16 | WeekRange.new(options) 17 | when options[:year] && options[:month] 18 | MonthRange.new(options) 19 | when options[:year] 20 | YearRange.new(options) 21 | else 22 | fail "Invalid range information #{options}" 23 | end 24 | end 25 | 26 | def self.range_from_time_for_period(start_time, period_name) 27 | case period_name 28 | when 'month' 29 | YearRange.from_time(start_time) 30 | when 'week' 31 | YearRange.from_time(start_time) 32 | when 'day' 33 | MonthRange.from_time(start_time) 34 | when 'hour' 35 | DayRange.from_time(start_time) 36 | end 37 | end 38 | 39 | def include?(time) 40 | time >= @start && 41 | time <= @finish 42 | end 43 | 44 | def initialize(options) 45 | @year = options[:year] 46 | @range_type = options[:range_type] || :normal 47 | @month = @week = @day = @hour = nil 48 | end 49 | 50 | def key 51 | "#{@year}:#{@month}:#{@week}:#{@day}:#{@hour}" 52 | end 53 | 54 | def human_readable_name 55 | self.class.name.gsub('DoubleEntry::Reporting::', '').gsub('Range', '') 56 | end 57 | end 58 | end 59 | end 60 | -------------------------------------------------------------------------------- /spec/double_entry/reporting/time_range_spec.rb: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | module DoubleEntry 3 | module Reporting 4 | RSpec.describe TimeRange do 5 | it 'should correctly calculate a month range' do 6 | ar = TimeRange.make(:year => 2009, :month => 10) 7 | expect(ar.start.to_s).to eq Time.mktime(2009, 10, 1, 0, 0, 0).to_s 8 | expect(ar.finish.to_s).to eq Time.mktime(2009, 10, 31, 23, 59, 59).to_s 9 | end 10 | 11 | it 'should correctly calculate the beginning of the financial year' do 12 | range = TimeRange.make(:year => 2009, :month => 6).beginning_of_financial_year 13 | expect(range.month).to eq 7 14 | expect(range.year).to eq 2008 15 | range = TimeRange.make(:year => 2009, :month => 7).beginning_of_financial_year 16 | expect(range.month).to eq 7 17 | expect(range.year).to eq 2009 18 | end 19 | 20 | it "should correctly calculate the current week range for New Year's Day" do 21 | Timecop.freeze Time.mktime(2009, 1, 1) do 22 | expect(WeekRange.current.week).to eq 1 23 | end 24 | end 25 | 26 | it 'should correctly calculate the current week range for the first Sunday in the year after New Years' do 27 | Timecop.freeze Time.mktime(2009, 1, 4) do 28 | expect(WeekRange.current.week).to eq 1 29 | end 30 | end 31 | 32 | it 'should correctly calculate the current week range for the first Monday in the year after New Years' do 33 | Timecop.freeze Time.mktime(2009, 1, 5) do 34 | expect(WeekRange.current.week).to eq 2 35 | end 36 | end 37 | 38 | it 'should correctly calculate the current week range for my birthday' do 39 | Timecop.freeze Time.mktime(2009, 3, 27) do 40 | expect(WeekRange.current.week).to eq 13 41 | end 42 | end 43 | end 44 | end 45 | end 46 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # DoubleEntry Reporting 2 | 3 | [![License MIT](https://img.shields.io/badge/license-MIT-brightgreen.svg)](https://github.com/envato/double_entry-reporting/blob/master/LICENSE.md) 4 | [![Gem Version](https://badge.fury.io/rb/double_entry-reporting.svg)](http://badge.fury.io/rb/double_entry-reporting) 5 | [![Build Status](https://github.com/envato/double_entry-reporting/actions/workflows/ci.yml/badge.svg)](https://github.com/envato/double_entry-reporting/actions/workflows/ci.yml) 6 | 7 | ## Installation 8 | 9 | In your application's `Gemfile`, add: 10 | 11 | ```ruby 12 | gem 'double_entry-reporting' 13 | ``` 14 | 15 | Download and install the gem with Bundler: 16 | 17 | ```sh 18 | bundle 19 | ``` 20 | 21 | Generate Rails schema migrations for the required tables: 22 | 23 | ```sh 24 | rails generate double_entry:reporting:install 25 | ``` 26 | 27 | Update the local database: 28 | 29 | ```sh 30 | rake db:migrate 31 | ``` 32 | 33 | ## Development Environment Setup 34 | 35 | 1. Clone this repo. 36 | 37 | ```sh 38 | git clone git@github.com:envato/double_entry-reporting.git && cd double_entry-reporting 39 | ``` 40 | 41 | 2. Run the included setup script to install the gem dependencies. 42 | 43 | ```sh 44 | ./script/setup.sh 45 | ``` 46 | 47 | 3. Install MySQL, PostgreSQL and SQLite. We run tests against all three databases. 48 | 4. Create a database in MySQL. 49 | 50 | ```sh 51 | mysql -u root -e 'create database double_entry_reporting_test;' 52 | ``` 53 | 54 | 5. Create a database in PostgreSQL. 55 | 56 | ```sh 57 | psql -c 'create database double_entry_reporting_test;' -U postgres 58 | ``` 59 | 60 | 6. Specify how the tests should connect to the database 61 | 62 | ```sh 63 | cp spec/support/{database.example.yml,database.yml} 64 | vim spec/support/database.yml 65 | ``` 66 | 67 | 7. Run the tests 68 | 69 | ```sh 70 | bundle exec rake 71 | ``` 72 | -------------------------------------------------------------------------------- /double_entry-reporting.gemspec: -------------------------------------------------------------------------------- 1 | 2 | lib = File.expand_path('../lib', __FILE__) 3 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 4 | 5 | require 'double_entry/reporting/version' 6 | 7 | Gem::Specification.new do |gem| 8 | gem.name = 'double_entry-reporting' 9 | gem.version = DoubleEntry::Reporting::VERSION 10 | gem.authors = ['Envato'] 11 | gem.email = ['rubygems@envato.com'] 12 | gem.summary = 'Tools to build your double entry financial ledger' 13 | gem.homepage = 'https://github.com/envato/double_entry-reporting' 14 | gem.license = 'MIT' 15 | 16 | gem.metadata = { 17 | 'bug_tracker_uri' => 'https://github.com/envato/double_entry-reporting/issues', 18 | 'changelog_uri' => "https://github.com/envato/double_entry-reporting/blob/v#{gem.version}/CHANGELOG.md", 19 | 'documentation_uri' => "https://www.rubydoc.info/gems/double_entry-reporting/#{gem.version}", 20 | 'source_code_uri' => "https://github.com/envato/double_entry-reporting/tree/v#{gem.version}", 21 | } 22 | 23 | gem.files = `git ls-files -z`.split("\x0").select do |f| 24 | f.match(%r{^(?:README|LICENSE|CHANGELOG|lib/)}) 25 | end 26 | gem.require_paths = ['lib'] 27 | gem.required_ruby_version = '>= 3.0.0' 28 | 29 | gem.add_dependency 'double_entry', '>= 2.0.0' 30 | gem.add_dependency 'activerecord', '>= 6.1.0' 31 | gem.add_dependency 'activesupport', '>= 6.1.0' 32 | gem.add_dependency 'money', '>= 6.0.0' 33 | gem.add_dependency 'railties', '>= 6.1.0' 34 | 35 | gem.add_development_dependency 'mysql2' 36 | gem.add_development_dependency 'pg' 37 | gem.add_development_dependency 'rake' 38 | gem.add_development_dependency 'sqlite3' 39 | 40 | gem.add_development_dependency 'database_cleaner' 41 | gem.add_development_dependency 'factory_bot' 42 | gem.add_development_dependency 'generator_spec' 43 | gem.add_development_dependency 'rspec' 44 | gem.add_development_dependency 'rspec-its' 45 | gem.add_development_dependency 'ruby-prof' 46 | gem.add_development_dependency 'timecop' 47 | end 48 | -------------------------------------------------------------------------------- /spec/performance/reporting/aggregate_performance_spec.rb: -------------------------------------------------------------------------------- 1 | module DoubleEntry 2 | module Reporting 3 | RSpec.describe Aggregate do 4 | include PerformanceHelper 5 | let(:user) { create(:user) } 6 | let(:amount) { Money.new(10_00) } 7 | let(:test) { DoubleEntry.account(:test, :scope => user) } 8 | let(:savings) { DoubleEntry.account(:savings, :scope => user) } 9 | 10 | subject(:transfer) { Transfer.transfer(amount, options) } 11 | 12 | context '200 transfers in a single day, half with metadata' do 13 | # Surprisingly, the number of transfers makes no difference to the time taken to aggregate them. Some sample results: 14 | # 20,000 => 524ms 15 | # 10,000 => 573ms 16 | # 1,000 => 486ms 17 | # 100 => 608ms 18 | # 10 => 509ms 19 | # 1 => 473ms 20 | before do 21 | Timecop.freeze Time.local(2015, 06, 30) do 22 | 100.times { Transfer.transfer(amount, :from => test, :to => savings, :code => :bonus) } 23 | 100.times { Transfer.transfer(amount, :from => test, :to => savings, :code => :bonus, :metadata => { :country => 'AU', :tax => 'GST' }) } 24 | end 25 | end 26 | 27 | it 'calculates monthly all_time ranges quickly without a filter' do 28 | profile_aggregation_with_filter(nil) 29 | # local results: 517ms, 484ms, 505ms, 482ms, 525ms 30 | end 31 | 32 | it 'calculates monthly all_time ranges quickly with a filter' do 33 | profile_aggregation_with_filter([:metadata => { :country => 'AU' }]) 34 | # local results when run independently (caching improves performance when run consecutively): 35 | # 655ms, 613ms, 597ms, 607ms, 627ms 36 | end 37 | end 38 | 39 | def profile_aggregation_with_filter(filter) 40 | start_profiling 41 | range = TimeRange.make(:year => 2015, :month => 06, :range_type => :all_time) 42 | Reporting.aggregate(function: :sum, account: :savings, code: :bonus, range: range, filter: filter) 43 | profile_name = filter ? 'aggregate-with-metadata' : 'aggregate' 44 | stop_profiling(profile_name) 45 | end 46 | end 47 | end 48 | end 49 | -------------------------------------------------------------------------------- /lib/double_entry/reporting/line_aggregate_filter.rb: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | module DoubleEntry 3 | module Reporting 4 | class LineAggregateFilter 5 | 6 | def initialize(account:, partner_account:, code:, range:, filter_criteria:) 7 | @account = account 8 | @partner_account = partner_account 9 | @code = code 10 | @range = range 11 | @filter_criteria = filter_criteria || [] 12 | end 13 | 14 | def filter 15 | @collection ||= apply_filters 16 | end 17 | 18 | private 19 | 20 | def apply_filters 21 | collection = apply_filter_criteria. 22 | where(:account => @account). 23 | where(:created_at => @range.start..@range.finish) 24 | collection = collection.where(:code => @code) if @code 25 | collection = collection.where(:partner_account => @partner_account) if @partner_account 26 | 27 | collection 28 | end 29 | 30 | # a lot of the trickier reports will use filters defined 31 | # in filter_criteria to bring in data from other tables. 32 | # For example: 33 | # 34 | # filter_criteria = [ 35 | # # an example of calling a named scope called with arguments 36 | # { 37 | # :scope => { 38 | # :name => :ten_dollar_purchases_by_category, 39 | # :arguments => [:cat_videos, :cat_pictures] 40 | # } 41 | # }, 42 | # # an example of calling a named scope with no arguments 43 | # { 44 | # :scope => { 45 | # :name => :ten_dollar_purchases 46 | # } 47 | # }, 48 | # # an example of providing multiple metadatum criteria to filter on 49 | # { 50 | # :metadata => { 51 | # :meme => :business_cat, 52 | # :category => :fun_times, 53 | # } 54 | # } 55 | # ] 56 | def apply_filter_criteria 57 | @filter_criteria.reduce(DoubleEntry::Line) do |collection, filter| 58 | if filter[:scope].present? 59 | filter_by_scope(collection, filter[:scope]) 60 | elsif filter[:metadata].present? 61 | filter_by_metadata(collection, filter[:metadata]) 62 | else 63 | collection 64 | end 65 | end 66 | end 67 | 68 | def filter_by_scope(collection, scope) 69 | collection.public_send(scope[:name], *scope[:arguments]) 70 | end 71 | 72 | def filter_by_metadata(collection, metadata) 73 | DoubleEntry::Reporting::LineMetadataFilter.filter(collection: collection, metadata: metadata) 74 | end 75 | end 76 | end 77 | end 78 | -------------------------------------------------------------------------------- /lib/double_entry/reporting/month_range.rb: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | module DoubleEntry 3 | module Reporting 4 | class MonthRange < TimeRange 5 | class << self 6 | def from_time(time) 7 | new(:year => time.year, :month => time.month) 8 | end 9 | 10 | def current 11 | from_time(Time.now) 12 | end 13 | 14 | # Obtain a sequence of MonthRanges from the given start to the current 15 | # month. 16 | # 17 | # @option options :from [Time] Time of the first in the returned sequence 18 | # of MonthRanges. 19 | # @return [Array] 20 | def reportable_months(options = {}) 21 | month = options[:from] ? from_time(options[:from]) : earliest_month 22 | last = current 23 | [month].tap do |months| 24 | while month != last 25 | month = month.next 26 | months << month 27 | end 28 | end 29 | end 30 | 31 | def earliest_month 32 | from_time(Reporting.configuration.start_of_business) 33 | end 34 | end 35 | 36 | attr_reader :year, :month 37 | 38 | def initialize(options = {}) 39 | super options 40 | 41 | if options.present? 42 | @month = options[:month] 43 | 44 | month_start = Time.local(year, options[:month], 1) 45 | @start = month_start 46 | @finish = month_start.end_of_month 47 | 48 | @start = MonthRange.earliest_month.start if options[:range_type] == :all_time 49 | end 50 | end 51 | 52 | def previous 53 | if month <= 1 54 | MonthRange.new :year => year - 1, :month => 12 55 | else 56 | MonthRange.new :year => year, :month => month - 1 57 | end 58 | end 59 | 60 | def next 61 | if month >= 12 62 | MonthRange.new :year => year + 1, :month => 1 63 | else 64 | MonthRange.new :year => year, :month => month + 1 65 | end 66 | end 67 | 68 | def beginning_of_financial_year 69 | first_month_of_financial_year = Reporting.configuration.first_month_of_financial_year 70 | year = (month >= first_month_of_financial_year) ? @year : (@year - 1) 71 | MonthRange.new(:year => year, :month => first_month_of_financial_year) 72 | end 73 | 74 | alias_method :succ, :next 75 | 76 | def <=>(other) 77 | start <=> other.start 78 | end 79 | 80 | def ==(other) 81 | month == other.month && 82 | year == other.year 83 | end 84 | 85 | def all_time 86 | MonthRange.new(:year => year, :month => month, :range_type => :all_time) 87 | end 88 | 89 | def to_s 90 | start.strftime('%Y, %b') 91 | end 92 | end 93 | end 94 | end 95 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) 6 | and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). 7 | 8 | ## [0.1.0] - 2019-01-27 9 | 10 | ### Added 11 | 12 | - `DoubleEntry::Reporting` module extracted from the `double_entry` gem. Please see 13 | the [Double Entry changelog](https://github.com/envato/double_entry/blob/master/CHANGELOG.md) 14 | for changes prior to this release. 15 | 16 | - Added support for Ruby 2.3, 2.4, 2.5 and 2.6. 17 | 18 | - Added support for Rails 5.0, 5.1 and 5.2 19 | 20 | - Allow filtering aggregates by multiple metadata key/value pairs. 21 | 22 | ### Changed 23 | 24 | - These methods now use keyword arguments. This is a breaking change. 25 | - `DoubleEntry::Reporting::aggregate` 26 | - `DoubleEntry::Reporting::aggregate_array` 27 | - `DoubleEntry::Reporting::Aggregate::new` 28 | - `DoubleEntry::Reporting::Aggregate::formatted_amount` 29 | - `DoubleEntry::Reporting::AggregateArray::new` 30 | - `DoubleEntry::Reporting::LineAggregateFilter::new` 31 | 32 | - Allow partner account to be specified for aggregates. This changes the DB 33 | schema. Apply this change with the migration: 34 | 35 | ```ruby 36 | add_column :double_entry_line_aggregates, :partner_account, :string, after: :code 37 | remove_index :double_entry_line_aggregates, name: :line_aggregate_idx 38 | add_index :double_entry_line_aggregates, %i[function account code partner_account year month week day], name: :line_aggregate_idx 39 | ``` 40 | 41 | - Replaced Machinist with Factory Bot in test suite. 42 | 43 | - Changed the `double_entry_line_aggregates.amount` column to be of type `bigint`. 44 | Apply this change with the migration: 45 | 46 | ```ruby 47 | change_column :double_entry_line_aggregates, :amount, :bigint, null: false 48 | ``` 49 | 50 | - Changed the maximum length of the `account`, `code` and `scope` columns. 51 | Apply this change with the migration: 52 | 53 | ```ruby 54 | change_column :double_entry_line_aggregates, :account, :string, null: false 55 | change_column :double_entry_line_aggregates, :code, :string, null: true 56 | change_column :double_entry_line_aggregates, :scope, :string, null: true 57 | ``` 58 | 59 | ### Removed 60 | 61 | - Removed support for Ruby 1.9, 2.0, 2.1 and 2.2. 62 | 63 | - Removed support for Rails 3.2, 4.0, and 4.1. 64 | 65 | - Removed `DoubleEntry::Reporting.scopes_with_minimum_balance_for_account` 66 | method. This is now available on the `DoubleEntry::AccountBalance` class. 67 | 68 | ### Fixed 69 | 70 | - Fixed Ruby warnings. 71 | 72 | - Fixed problem of Rails version number not being set in migration template for apps using Rails 5 or higher. 73 | 74 | [Unreleased]: https://github.com/envato/double_entry/compare/v0.1.0...HEAD 75 | [0.1.0]: https://github.com/envato/double_entry-reporting/compare/double-entry-v1.0.0...v0.1.0 76 | -------------------------------------------------------------------------------- /lib/double_entry/reporting/week_range.rb: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | module DoubleEntry 3 | module Reporting 4 | # We use a particularly crazy week numbering system: week 1 of any given year 5 | # is the first week with any days that fall into that year. 6 | # 7 | # So, for example, week 1 of 2011 starts on 27 Dec 2010. 8 | class WeekRange < TimeRange 9 | class << self 10 | def from_time(time) 11 | date = time.to_date 12 | week = date.cweek 13 | year = date.end_of_week.year 14 | 15 | if date.beginning_of_week.year != year 16 | week = 1 17 | elsif date.beginning_of_year.cwday > Date::DAYNAMES.index('Thursday') 18 | week += 1 19 | end 20 | 21 | new(:year => year, :week => week) 22 | end 23 | 24 | def current 25 | from_time(Time.now) 26 | end 27 | 28 | # Obtain a sequence of WeekRanges from the given start to the current 29 | # week. 30 | # 31 | # @option options :from [Time] Time of the first in the returned sequence 32 | # of WeekRanges. 33 | # @return [Array] 34 | def reportable_weeks(options = {}) 35 | week = options[:from] ? from_time(options[:from]) : earliest_week 36 | last_in_sequence = current 37 | [week].tap do |weeks| 38 | while week != last_in_sequence 39 | week = week.next 40 | weeks << week 41 | end 42 | end 43 | end 44 | 45 | private 46 | 47 | def start_of_year(year) 48 | Time.local(year, 1, 1).beginning_of_week 49 | end 50 | 51 | def earliest_week 52 | from_time(Reporting.configuration.start_of_business) 53 | end 54 | end 55 | 56 | attr_reader :year, :week 57 | 58 | def initialize(options = {}) 59 | super options 60 | 61 | if options.present? 62 | @week = options[:week] 63 | 64 | @start = week_and_year_to_time(@week, @year) 65 | @finish = @start.end_of_week 66 | 67 | @start = earliest_week.start if options[:range_type] == :all_time 68 | end 69 | end 70 | 71 | def previous 72 | from_time(@start - 1.week) 73 | end 74 | 75 | def next 76 | from_time(@start + 1.week) 77 | end 78 | 79 | def ==(other) 80 | week == other.week && 81 | year == other.year 82 | end 83 | 84 | def all_time 85 | self.class.new(:year => year, :week => week, :range_type => :all_time) 86 | end 87 | 88 | def to_s 89 | "#{year}, Week #{week}" 90 | end 91 | 92 | private 93 | 94 | def from_time(time) 95 | self.class.from_time(time) 96 | end 97 | 98 | def earliest_week 99 | self.class.send(:earliest_week) 100 | end 101 | 102 | def week_and_year_to_time(week, year) 103 | self.class.send(:start_of_year, year) + (week - 1).weeks 104 | end 105 | end 106 | end 107 | end 108 | -------------------------------------------------------------------------------- /lib/double_entry/reporting/aggregate_array.rb: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | module DoubleEntry 3 | module Reporting 4 | class AggregateArray < Array 5 | # An AggregateArray is awesome 6 | # It is useful for making reports 7 | # It is basically an array of aggregate results, 8 | # representing a column of data in a report. 9 | # 10 | # For example, you could request all sales 11 | # broken down by month and it would return an array of values 12 | attr_reader :function, :account, :partner_account, :code, :filter, :range_type, :start, :finish, :currency 13 | 14 | def initialize(function:, account:, code:, partner_account: nil, filter: nil, range_type: nil, start: nil, finish: nil) 15 | @function = function.to_s 16 | @account = account 17 | @code = code 18 | @partner_account = partner_account 19 | @filter = filter 20 | @range_type = range_type 21 | @start = start 22 | @finish = finish 23 | @currency = DoubleEntry::Account.currency(account) 24 | 25 | retrieve_aggregates 26 | fill_in_missing_aggregates 27 | populate_self 28 | end 29 | 30 | private 31 | 32 | def populate_self 33 | all_periods.each do |period| 34 | self << @aggregates[period.key] 35 | end 36 | end 37 | 38 | def fill_in_missing_aggregates 39 | # some aggregates may not have been previously calculated, so we can request them now 40 | # (this includes aggregates for the still-running period) 41 | all_periods.each do |period| 42 | unless @aggregates[period.key] 43 | @aggregates[period.key] = Aggregate.formatted_amount(function: function, account: account, code: code, 44 | range: period, partner_account: partner_account, filter: filter) 45 | end 46 | end 47 | end 48 | 49 | # get any previously calculated aggregates 50 | def retrieve_aggregates 51 | fail ArgumentError, "Invalid range type '#{range_type}'" unless %w(year month week day hour).include? range_type 52 | scope = LineAggregate. 53 | where(:function => function). 54 | where(:range_type => 'normal'). 55 | where(:account => account.try(:to_s)). 56 | where(:partner_account => partner_account.try(:to_s)). 57 | where(:code => code.try(:to_s)). 58 | where(:filter => filter.inspect). 59 | where(LineAggregate.arel_table[range_type].not_eq(nil)) 60 | @aggregates = scope.each_with_object({}) do |result, hash| 61 | hash[result.key] = formatted_amount(result.amount) 62 | end 63 | end 64 | 65 | def all_periods 66 | TimeRangeArray.make(range_type, start, finish) 67 | end 68 | 69 | def formatted_amount(amount) 70 | amount ||= 0 71 | if function == 'count' 72 | amount 73 | else 74 | Money.new(amount, currency) 75 | end 76 | end 77 | end 78 | end 79 | end 80 | -------------------------------------------------------------------------------- /spec/support/schema.rb: -------------------------------------------------------------------------------- 1 | ActiveRecord::Schema.define do 2 | self.verbose = false 3 | 4 | create_table "double_entry_account_balances", :force => true do |t| 5 | t.string "account", :null => false 6 | t.string "scope" 7 | t.bigint "balance", :null => false 8 | t.timestamps :null => false 9 | end 10 | 11 | add_index "double_entry_account_balances", ["account"], :name => "index_account_balances_on_account" 12 | add_index "double_entry_account_balances", ["scope", "account"], :name => "index_account_balances_on_scope_and_account", :unique => true 13 | 14 | create_table "double_entry_lines", :force => true do |t| 15 | t.string "account", :null => false 16 | t.string "scope" 17 | t.string "code", :null => false 18 | t.bigint "amount", :null => false 19 | t.bigint "balance", :null => false 20 | t.references "partner", :index => false 21 | t.string "partner_account", :null => false 22 | t.string "partner_scope" 23 | t.references "detail", :index => false, :polymorphic => true 24 | t.timestamps :null => false 25 | end 26 | 27 | add_index "double_entry_lines", ["account", "code", "created_at", "partner_account"], :name => "lines_account_code_created_at_partner_account_idx" 28 | add_index "double_entry_lines", ["account", "created_at"], :name => "lines_account_created_at_idx" 29 | add_index "double_entry_lines", ["scope", "account", "created_at"], :name => "lines_scope_account_created_at_idx" 30 | add_index "double_entry_lines", ["scope", "account", "id"], :name => "lines_scope_account_id_idx" 31 | 32 | create_table "double_entry_line_aggregates", :force => true do |t| 33 | t.string "function", :limit => 15, :null => false 34 | t.string "account", :null => false 35 | t.string "code" 36 | t.string "partner_account" 37 | t.string "scope" 38 | t.integer "year" 39 | t.integer "month" 40 | t.integer "week" 41 | t.integer "day" 42 | t.integer "hour" 43 | t.bigint "amount", :null => false 44 | t.string "filter" 45 | t.string "range_type", :limit => 15, :null => false 46 | t.timestamps :null => false 47 | end 48 | 49 | add_index "double_entry_line_aggregates", ["function", "account", "code", "partner_account", "year", "month", "week", "day"], :name => "line_aggregate_idx" 50 | 51 | create_table "double_entry_line_checks", :force => true do |t| 52 | t.references "last_line", :null => false, :index => false 53 | t.boolean "errors_found", :null => false 54 | t.text "log" 55 | t.timestamps :null => false 56 | end 57 | 58 | add_index "double_entry_line_checks", ["created_at", "last_line_id"], :name => "line_checks_created_at_last_line_id_idx" 59 | 60 | create_table "double_entry_line_metadata", :force => true do |t| 61 | t.references "line", :null => false, :index => false 62 | t.string "key", :null => false 63 | t.string "value", :null => false 64 | t.timestamps :null => false 65 | end 66 | 67 | add_index "double_entry_line_metadata", ["line_id", "key", "value"], :name => "lines_meta_line_id_key_value_idx" 68 | 69 | # test table only 70 | create_table "users", :force => true do |t| 71 | t.string "username", :null => false 72 | t.timestamps :null => false 73 | end 74 | 75 | add_index "users", ["username"], :name => "index_users_on_username", :unique => true 76 | end 77 | -------------------------------------------------------------------------------- /spec/double_entry/reporting/week_range_spec.rb: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | module DoubleEntry 3 | module Reporting 4 | RSpec.describe WeekRange do 5 | it 'should start week 1 of a year in the first week that has any day in the year' do 6 | range = WeekRange.new(:year => 2011, :week => 1) 7 | expect(range.start).to eq Time.parse('2010-12-27 00:00:00') 8 | end 9 | 10 | it 'should handle times in the last week of the year properly' do 11 | range = WeekRange.from_time(Time.parse('2010-12-29 11:30:00')) 12 | expect(range.year).to eq 2011 13 | expect(range.week).to eq 1 14 | expect(range.start).to eq Time.parse('2010-12-27 00:00:00') 15 | end 16 | 17 | it 'handles daylight savings time properly' do 18 | Time.use_zone('America/Los_Angeles') do 19 | time = Time.zone.parse('Mon, 10 Mar 2014') 20 | range = WeekRange.from_time time 21 | expect(range.start.day).to eq 10 22 | end 23 | end 24 | 25 | describe '::from_time' do 26 | subject(:from_time) { WeekRange.from_time(given_time) } 27 | 28 | context 'given the Time 31st March 2012' do 29 | let(:given_time) { Time.new(2012, 3, 31) } 30 | its(:year) { should eq 2012 } 31 | its(:week) { should eq 14 } 32 | end 33 | 34 | context 'given the Date 31st March 2012' do 35 | let(:given_time) { Date.parse('2012-03-31') } 36 | its(:year) { should eq 2012 } 37 | its(:week) { should eq 14 } 38 | end 39 | end 40 | 41 | describe '::reportable_weeks' do 42 | subject(:reportable_weeks) { WeekRange.reportable_weeks } 43 | 44 | context 'The date is 1st Feb 1970' do 45 | before { Timecop.freeze(Time.new(1970, 2, 1)) } 46 | 47 | specify do 48 | should eq [ 49 | WeekRange.new(:year => 1970, :week => 1), 50 | WeekRange.new(:year => 1970, :week => 2), 51 | WeekRange.new(:year => 1970, :week => 3), 52 | WeekRange.new(:year => 1970, :week => 4), 53 | WeekRange.new(:year => 1970, :week => 5), 54 | ] 55 | end 56 | 57 | context 'My business started on 25th Jan 1970' do 58 | before do 59 | DoubleEntry::Reporting.configure do |config| 60 | config.start_of_business = Time.new(1970, 1, 25) 61 | end 62 | end 63 | 64 | specify do 65 | should eq [ 66 | WeekRange.new(:year => 1970, :week => 4), 67 | WeekRange.new(:year => 1970, :week => 5), 68 | ] 69 | end 70 | end 71 | end 72 | 73 | context 'The date is 1st Jan 1970' do 74 | before { Timecop.freeze(Time.new(1970, 1, 1)) } 75 | 76 | it { should eq [WeekRange.new(:year => 1970, :week => 1)] } 77 | end 78 | 79 | context 'Given a start time of 3rd Dec 1982' do 80 | subject(:reportable_weeks) { WeekRange.reportable_weeks(:from => Time.new(1982, 12, 3)) } 81 | 82 | context 'The date is 12nd Jan 1983' do 83 | before { Timecop.freeze(Time.new(1983, 2, 2)) } 84 | specify do 85 | should eq [ 86 | WeekRange.new(:year => 1982, :week => 49), 87 | WeekRange.new(:year => 1982, :week => 50), 88 | WeekRange.new(:year => 1982, :week => 51), 89 | WeekRange.new(:year => 1982, :week => 52), 90 | WeekRange.new(:year => 1983, :week => 1), 91 | WeekRange.new(:year => 1983, :week => 2), 92 | WeekRange.new(:year => 1983, :week => 3), 93 | WeekRange.new(:year => 1983, :week => 4), 94 | WeekRange.new(:year => 1983, :week => 5), 95 | WeekRange.new(:year => 1983, :week => 6), 96 | ] 97 | end 98 | end 99 | end 100 | end 101 | end 102 | end 103 | end 104 | -------------------------------------------------------------------------------- /lib/double_entry/reporting/aggregate.rb: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | module DoubleEntry 3 | module Reporting 4 | class Aggregate 5 | attr_reader :function, :account, :partner_account, :code, :range, :filter, :currency 6 | 7 | def self.formatted_amount(function:, account:, code:, range:, partner_account: nil, filter: nil) 8 | new( 9 | function: function, 10 | account: account, 11 | code: code, 12 | range: range, 13 | partner_account: partner_account, 14 | filter: filter, 15 | ).formatted_amount 16 | end 17 | 18 | def initialize(function:, account:, code:, range:, partner_account: nil, filter: nil) 19 | @function = function.to_s 20 | fail AggregateFunctionNotSupported unless %w(sum count average).include?(@function) 21 | 22 | @account = account 23 | @code = code.try(:to_s) 24 | @range = range 25 | @partner_account = partner_account 26 | @filter = filter 27 | @currency = DoubleEntry::Account.currency(account) 28 | end 29 | 30 | def amount(force_recalculation = false) 31 | if force_recalculation 32 | clear_old_aggregates 33 | calculate 34 | else 35 | retrieve || calculate 36 | end 37 | end 38 | 39 | def formatted_amount(value = amount) 40 | value ||= 0 41 | if function == 'count' 42 | value 43 | else 44 | Money.new(value, currency) 45 | end 46 | end 47 | 48 | private 49 | 50 | def retrieve 51 | aggregate = LineAggregate.where(field_hash).first 52 | aggregate.amount if aggregate 53 | end 54 | 55 | def clear_old_aggregates 56 | LineAggregate.delete_all(field_hash) 57 | end 58 | 59 | def calculate 60 | if range.class == YearRange 61 | aggregate = calculate_yearly_aggregate 62 | else 63 | aggregate = LineAggregate.aggregate( 64 | function: function, 65 | account: account, 66 | partner_account: partner_account, 67 | code: code, 68 | range: range, 69 | named_scopes: filter 70 | ) 71 | end 72 | 73 | if range_is_complete? 74 | fields = field_hash 75 | fields[:amount] = aggregate || 0 76 | LineAggregate.create! fields 77 | end 78 | 79 | aggregate 80 | end 81 | 82 | def calculate_yearly_aggregate 83 | # We calculate yearly aggregates by combining monthly aggregates 84 | # otherwise they will get excruciatingly slow to calculate 85 | # as the year progresses. (I am thinking mainly of the 'current' year.) 86 | # Combining monthly aggregates will mean that the figure will be partially memoized 87 | if function == 'average' 88 | calculate_yearly_average 89 | else 90 | result = (1..12).inject(formatted_amount(0)) do |total, month| 91 | total + Aggregate.new(function: function, account: account, code: code, 92 | range: MonthRange.new(:year => range.year, :month => month), 93 | partner_account: partner_account, filter: filter).formatted_amount 94 | end 95 | result.is_a?(Money) ? result.cents : result 96 | end 97 | end 98 | 99 | def calculate_yearly_average 100 | # need this seperate function, because an average of averages is not the correct average 101 | year_range = YearRange.new(:year => range.year) 102 | sum = Aggregate.new(function: :sum, account: account, code: code, range: year_range, 103 | partner_account: partner_account, filter: filter).formatted_amount 104 | count = Aggregate.new(function: :count, account: account, code: code, range: year_range, 105 | partner_account: partner_account, filter: filter).formatted_amount 106 | (count == 0) ? 0 : (sum / count).cents 107 | end 108 | 109 | def range_is_complete? 110 | Time.now > range.finish 111 | end 112 | 113 | def field_hash 114 | { 115 | :function => function, 116 | :account => account, 117 | :partner_account => partner_account, 118 | :code => code, 119 | :year => range.year, 120 | :month => range.month, 121 | :week => range.week, 122 | :day => range.day, 123 | :hour => range.hour, 124 | :filter => filter.inspect, 125 | :range_type => range.range_type.to_s, 126 | } 127 | end 128 | end 129 | end 130 | end 131 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | # This file was generated by the `rspec --init` command. Conventionally, all 2 | # specs live under a `spec` directory, which RSpec adds to the `$LOAD_PATH`. 3 | # The generated `.rspec` file contains `--require spec_helper` which will cause 4 | # this file to always be loaded, without a need to explicitly require it in any 5 | # files. 6 | # 7 | # Given that it is always loaded, you are encouraged to keep this file as 8 | # light-weight as possible. Requiring heavyweight dependencies from this file 9 | # will add to the boot time of your test suite on EVERY test run, even for an 10 | # individual file that may not need all of that loaded. Instead, consider making 11 | # a separate helper file that requires the additional dependencies and performs 12 | # the additional setup, and require it from the spec files that actually need 13 | # it. 14 | # 15 | # See http://rubydoc.info/gems/rspec-core/RSpec/Core/Configuration 16 | RSpec.configure do |config| 17 | # rspec-expectations config goes here. You can use an alternate 18 | # assertion/expectation library such as wrong or the stdlib/minitest 19 | # assertions if you prefer. 20 | config.expect_with :rspec do |expectations| 21 | # This option will default to `true` in RSpec 4. It makes the `description` 22 | # and `failure_message` of custom matchers include text for helper methods 23 | # defined using `chain`, e.g.: 24 | # be_bigger_than(2).and_smaller_than(4).description 25 | # # => "be bigger than 2 and smaller than 4" 26 | # ...rather than: 27 | # # => "be bigger than 2" 28 | expectations.include_chain_clauses_in_custom_matcher_descriptions = true 29 | end 30 | 31 | # rspec-mocks config goes here. You can use an alternate test double 32 | # library (such as bogus or mocha) by changing the `mock_with` option here. 33 | config.mock_with :rspec do |mocks| 34 | # Prevents you from mocking or stubbing a method that does not exist on 35 | # a real object. This is generally recommended, and will default to 36 | # `true` in RSpec 4. 37 | mocks.verify_partial_doubles = true 38 | end 39 | 40 | # This option will default to `:apply_to_host_groups` in RSpec 4 (and will 41 | # have no way to turn it off -- the option exists only for backwards 42 | # compatibility in RSpec 3). It causes shared context metadata to be 43 | # inherited by the metadata hash of host groups and examples, rather than 44 | # triggering implicit auto-inclusion in groups with matching metadata. 45 | config.shared_context_metadata_behavior = :apply_to_host_groups 46 | 47 | # This allows you to limit a spec run to individual examples or groups 48 | # you care about by tagging them with `:focus` metadata. When nothing 49 | # is tagged with `:focus`, all examples get run. RSpec also provides 50 | # aliases for `it`, `describe`, and `context` that include `:focus` 51 | # metadata: `fit`, `fdescribe` and `fcontext`, respectively. 52 | config.filter_run_when_matching :focus 53 | 54 | # Allows RSpec to persist some state between runs in order to support 55 | # the `--only-failures` and `--next-failure` CLI options. We recommend 56 | # you configure your source control system to ignore this file. 57 | config.example_status_persistence_file_path = "spec/examples.txt" 58 | 59 | # Limits the available syntax to the non-monkey patched syntax that is 60 | # recommended. For more details, see: 61 | # - http://rspec.info/blog/2012/06/rspecs-new-expectation-syntax/ 62 | # - http://www.teaisaweso.me/blog/2013/05/27/rspecs-new-message-expectation-syntax/ 63 | # - http://rspec.info/blog/2014/05/notable-changes-in-rspec-3/#zero-monkey-patching-mode 64 | config.disable_monkey_patching! 65 | 66 | # This setting enables warnings. It's recommended, but in some cases may 67 | # be too noisy due to issues in dependencies. 68 | config.warnings = true 69 | 70 | # Many RSpec users commonly either run the entire suite or an individual 71 | # file, and it's useful to allow more verbose output when running an 72 | # individual spec file. 73 | if config.files_to_run.one? 74 | # Use the documentation formatter for detailed output, 75 | # unless a formatter has already been configured 76 | # (e.g. via a command-line flag). 77 | config.default_formatter = "doc" 78 | end 79 | 80 | # Print the 10 slowest examples and example groups at the 81 | # end of the spec run, to help surface which specs are running 82 | # particularly slow. 83 | # config.profile_examples = 10 84 | 85 | # Run specs in random order to surface order dependencies. If you find an 86 | # order dependency and want to debug it, you can fix the order by providing 87 | # the seed, which is printed after each run. 88 | # --seed 1234 89 | config.order = :random 90 | 91 | # Seed global randomization in this process using the `--seed` CLI option. 92 | # Setting this allows you to use `--seed` to deterministically reproduce 93 | # test failures related to randomization by passing the same `--seed` value 94 | # as the one that triggered the failure. 95 | Kernel.srand config.seed 96 | end 97 | -------------------------------------------------------------------------------- /spec/double_entry/reporting/line_aggregate_filter_spec.rb: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | RSpec.describe DoubleEntry::Reporting::LineAggregateFilter do 3 | describe '.filter' do 4 | let(:function) { :sum } 5 | let(:account) { :account } 6 | let(:code) { :transfer_code } 7 | let(:filter_criteria) { nil } 8 | let(:partner_account) { nil } 9 | let(:start) { Time.parse('2014-07-27 10:55:44 +1000') } 10 | let(:finish) { Time.parse('2015-07-27 10:55:44 +1000') } 11 | let(:range) do 12 | instance_double(DoubleEntry::Reporting::MonthRange, :start => start, :finish => finish) 13 | end 14 | 15 | let(:lines_scope) { spy(DoubleEntry::Line) } 16 | 17 | subject(:filter) do 18 | DoubleEntry::Reporting::LineAggregateFilter.new( 19 | account: account, 20 | partner_account: partner_account, 21 | code: code, 22 | range: range, 23 | filter_criteria: filter_criteria 24 | ) 25 | end 26 | 27 | before do 28 | stub_const('DoubleEntry::Line', lines_scope) 29 | 30 | allow(DoubleEntry::Reporting::LineMetadataFilter).to receive(:filter).and_call_original 31 | 32 | allow(lines_scope).to receive(:where).and_return(lines_scope) 33 | allow(lines_scope).to receive(:joins).and_return(lines_scope) 34 | allow(lines_scope).to receive(:ten_dollar_purchases).and_return(lines_scope) 35 | allow(lines_scope).to receive(:ten_dollar_purchases_by_category).and_return(lines_scope) 36 | 37 | filter.filter 38 | end 39 | 40 | context 'with named scopes specified' do 41 | let(:filter_criteria) do 42 | [ 43 | # an example of calling a named scope called with arguments 44 | { 45 | :scope => { 46 | :name => :ten_dollar_purchases_by_category, 47 | :arguments => [:cat_videos, :cat_pictures], 48 | }, 49 | }, 50 | # an example of calling a named scope with no arguments 51 | { 52 | :scope => { 53 | :name => :ten_dollar_purchases, 54 | }, 55 | }, 56 | # an example of providing a single metadatum criteria to filter on 57 | { 58 | :metadata => { 59 | :meme => 'business_cat', 60 | }, 61 | }, 62 | ] 63 | end 64 | 65 | it 'filters by all the scopes provided' do 66 | expect(DoubleEntry::Line).to have_received(:ten_dollar_purchases) 67 | expect(DoubleEntry::Line).to have_received(:ten_dollar_purchases_by_category). 68 | with(:cat_videos, :cat_pictures) 69 | end 70 | 71 | it 'filters by all the metadata provided' do 72 | expect(DoubleEntry::Reporting::LineMetadataFilter).to have_received(:filter). 73 | with(collection: anything, metadata: { :meme => 'business_cat'}) 74 | end 75 | end 76 | 77 | context 'with a code specified and partner_account not specified' do 78 | let(:code) { :transfer_code } 79 | let(:partner_account) { nil } 80 | 81 | it 'retrieves the appropriate lines for aggregation' do 82 | expect(DoubleEntry::Line).to have_received(:where).with(:account => account) 83 | expect(DoubleEntry::Line).to have_received(:where).with(:created_at => start..finish) 84 | expect(DoubleEntry::Line).to have_received(:where).with(:code => code) 85 | end 86 | end 87 | 88 | context 'with a partner_account specified and code not specified' do 89 | let(:code) { nil } 90 | let(:partner_account) { :partner_account } 91 | 92 | it 'retrieves the appropriate lines for aggregation' do 93 | expect(DoubleEntry::Line).to have_received(:where).with(:account => account) 94 | expect(DoubleEntry::Line).to have_received(:where).with(:created_at => start..finish) 95 | expect(DoubleEntry::Line).to have_received(:where).with(:partner_account => partner_account) 96 | end 97 | end 98 | 99 | context 'with code and partner_account specified' do 100 | let(:code) { :transfer_code } 101 | let(:partner_account) { :partner_account } 102 | 103 | it 'retrieves the appropriate lines for aggregation' do 104 | expect(DoubleEntry::Line).to have_received(:where).with(:account => account) 105 | expect(DoubleEntry::Line).to have_received(:where).with(:created_at => start..finish) 106 | expect(DoubleEntry::Line).to have_received(:where).with(:code => code) 107 | expect(DoubleEntry::Line).to have_received(:where).with(:partner_account => partner_account) 108 | end 109 | end 110 | 111 | context 'with no code or partner_account specified' do 112 | let(:code) { nil } 113 | let(:partner_account) { nil } 114 | 115 | it 'retrieves the appropriate lines for aggregation' do 116 | expect(DoubleEntry::Line).to have_received(:where).with(:account => account) 117 | expect(DoubleEntry::Line).to have_received(:where).with(:created_at => start..finish) 118 | end 119 | end 120 | end 121 | end 122 | -------------------------------------------------------------------------------- /spec/double_entry/reporting/month_range_spec.rb: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | module DoubleEntry 3 | module Reporting 4 | RSpec.describe MonthRange do 5 | describe '.from_time' do 6 | subject(:from_time) { MonthRange.from_time(given_time) } 7 | 8 | context 'given the Time 31st March 2012' do 9 | let(:given_time) { Time.new(2012, 3, 31) } 10 | its(:year) { should eq 2012 } 11 | its(:month) { should eq 3 } 12 | end 13 | 14 | context 'given the Date 31st March 2012' do 15 | let(:given_time) { Date.parse('2012-03-31') } 16 | its(:year) { should eq 2012 } 17 | its(:month) { should eq 3 } 18 | end 19 | end 20 | 21 | describe '.reportable_months' do 22 | subject(:reportable_months) { MonthRange.reportable_months } 23 | 24 | context 'The date is 1st March 1970' do 25 | before { Timecop.freeze(Time.new(1970, 3, 1)) } 26 | 27 | specify do 28 | should eq [ 29 | MonthRange.new(:year => 1970, :month => 1), 30 | MonthRange.new(:year => 1970, :month => 2), 31 | MonthRange.new(:year => 1970, :month => 3), 32 | ] 33 | end 34 | 35 | context 'My business started on 5th Feb 1970' do 36 | before do 37 | DoubleEntry::Reporting.configure do |config| 38 | config.start_of_business = Time.new(1970, 2, 5) 39 | end 40 | end 41 | 42 | specify do 43 | should eq [ 44 | MonthRange.new(:year => 1970, :month => 2), 45 | MonthRange.new(:year => 1970, :month => 3), 46 | ] 47 | end 48 | end 49 | end 50 | 51 | context 'The date is 1st Jan 1970' do 52 | before { Timecop.freeze(Time.new(1970, 1, 1)) } 53 | 54 | it { should eq [MonthRange.new(:year => 1970, :month => 1)] } 55 | end 56 | end 57 | 58 | describe '.beginning_of_financial_year' do 59 | let(:month_range) { MonthRange.new(:year => year, :month => month) } 60 | let(:year) { 2014 } 61 | 62 | context 'the first month of the financial year is July' do 63 | subject(:beginning_of_financial_year) { month_range.beginning_of_financial_year } 64 | context 'returns the current year if the month is after July' do 65 | let(:month) { 10 } 66 | it { should eq(MonthRange.new(:year => 2014, :month => 7)) } 67 | end 68 | 69 | context 'returns the previous year if the month is before July' do 70 | let(:month) { 3 } 71 | it { should eq(MonthRange.new(:year => 2013, :month => 7)) } 72 | end 73 | end 74 | 75 | context 'the first month of the financial year is January' do 76 | subject(:beginning_of_financial_year) { month_range.beginning_of_financial_year } 77 | 78 | before do 79 | DoubleEntry::Reporting.configure do |config| 80 | config.first_month_of_financial_year = 1 81 | end 82 | end 83 | 84 | context 'returns the current year if the month is after January' do 85 | let(:month) { 10 } 86 | it { should eq(MonthRange.new(:year => 2014, :month => 1)) } 87 | end 88 | 89 | context 'returns the current year if the month is January' do 90 | let(:month) { 1 } 91 | it { should eq(MonthRange.new(:year => 2014, :month => 1)) } 92 | end 93 | end 94 | 95 | context 'the first month of the financial year is December' do 96 | subject(:beginning_of_financial_year) { month_range.beginning_of_financial_year } 97 | 98 | before do 99 | DoubleEntry::Reporting.configure do |config| 100 | config.first_month_of_financial_year = 12 101 | end 102 | end 103 | 104 | context 'returns the previous year if the month is before December (in the same year)' do 105 | let(:month) { 11 } 106 | it { should eq(MonthRange.new(:year => 2013, :month => 12)) } 107 | end 108 | 109 | context 'returns the previous year if the month is after December (in the next year)' do 110 | let(:year) { 2015 } 111 | let(:month) { 1 } 112 | it { should eq(MonthRange.new(:year => 2014, :month => 12)) } 113 | end 114 | 115 | context 'returns the current year if the month is December' do 116 | let(:month) { 12 } 117 | it { should eq(MonthRange.new(:year => 2014, :month => 12)) } 118 | end 119 | end 120 | 121 | context 'Given a start time of 3rd Dec 1982' do 122 | subject(:reportable_months) { MonthRange.reportable_months(:from => Time.new(1982, 12, 3)) } 123 | 124 | context 'The date is 2nd Feb 1983' do 125 | before { Timecop.freeze(Time.new(1983, 2, 2)) } 126 | 127 | specify do 128 | should eq [ 129 | MonthRange.new(:year => 1982, :month => 12), 130 | MonthRange.new(:year => 1983, :month => 1), 131 | MonthRange.new(:year => 1983, :month => 2), 132 | ] 133 | end 134 | end 135 | end 136 | end 137 | end 138 | end 139 | end 140 | -------------------------------------------------------------------------------- /spec/double_entry/reporting/time_range_array_spec.rb: -------------------------------------------------------------------------------- 1 | module DoubleEntry 2 | module Reporting 3 | RSpec.describe TimeRangeArray do 4 | describe '.make' do 5 | subject(:time_range_array) { TimeRangeArray.make(range_type, start, finish) } 6 | 7 | context 'for "hour" range type' do 8 | let(:range_type) { 'hour' } 9 | 10 | context 'given start is "2007-05-03 15:00:00" and finish is "2007-05-03 18:00:00"' do 11 | let(:start) { '2007-05-03 15:00:00' } 12 | let(:finish) { '2007-05-03 18:00:00' } 13 | specify do 14 | should eq [ 15 | HourRange.from_time(Time.new(2007, 5, 3, 15)), 16 | HourRange.from_time(Time.new(2007, 5, 3, 16)), 17 | HourRange.from_time(Time.new(2007, 5, 3, 17)), 18 | HourRange.from_time(Time.new(2007, 5, 3, 18)), 19 | ] 20 | end 21 | end 22 | 23 | context 'given start and finish are nil' do 24 | it 'should raise an error' do 25 | expect { TimeRangeArray.make(range_type, nil, nil) }. 26 | to raise_error 'Must specify start of range' 27 | end 28 | end 29 | end 30 | 31 | context 'for "day" range type' do 32 | let(:range_type) { 'day' } 33 | 34 | context 'given start is "2007-05-03" and finish is "2007-05-07"' do 35 | let(:start) { '2007-05-03' } 36 | let(:finish) { '2007-05-07' } 37 | specify do 38 | should eq [ 39 | DayRange.from_time(Time.new(2007, 5, 3)), 40 | DayRange.from_time(Time.new(2007, 5, 4)), 41 | DayRange.from_time(Time.new(2007, 5, 5)), 42 | DayRange.from_time(Time.new(2007, 5, 6)), 43 | DayRange.from_time(Time.new(2007, 5, 7)), 44 | ] 45 | end 46 | end 47 | 48 | context 'given start and finish are nil' do 49 | it 'should raise an error' do 50 | expect { TimeRangeArray.make(range_type, nil, nil) }. 51 | to raise_error 'Must specify start of range' 52 | end 53 | end 54 | end 55 | 56 | context 'for "week" range type' do 57 | let(:range_type) { 'week' } 58 | 59 | context 'given start is "2007-05-03" and finish is "2007-05-24"' do 60 | let(:start) { '2007-05-03' } 61 | let(:finish) { '2007-05-24' } 62 | specify do 63 | should eq [ 64 | WeekRange.from_time(Time.new(2007, 5, 3)), 65 | WeekRange.from_time(Time.new(2007, 5, 10)), 66 | WeekRange.from_time(Time.new(2007, 5, 17)), 67 | WeekRange.from_time(Time.new(2007, 5, 24)), 68 | ] 69 | end 70 | end 71 | 72 | context 'given start and finish are nil' do 73 | it 'should raise an error' do 74 | expect { TimeRangeArray.make(range_type, nil, nil) }. 75 | to raise_error 'Must specify start of range' 76 | end 77 | end 78 | end 79 | 80 | context 'for "month" range type' do 81 | let(:range_type) { 'month' } 82 | 83 | context 'given start is "2007-05-03" and finish is "2007-08-24"' do 84 | let(:start) { '2007-05-03' } 85 | let(:finish) { '2007-08-24' } 86 | specify do 87 | should eq [ 88 | MonthRange.from_time(Time.new(2007, 5)), 89 | MonthRange.from_time(Time.new(2007, 6)), 90 | MonthRange.from_time(Time.new(2007, 7)), 91 | MonthRange.from_time(Time.new(2007, 8)), 92 | ] 93 | end 94 | end 95 | 96 | context 'given finish is nil' do 97 | let(:start) { '2006-08-03' } 98 | let(:finish) { nil } 99 | 100 | context 'and the date is "2007-04-13"' do 101 | before { Timecop.freeze(Time.new(2007, 4, 13)) } 102 | 103 | specify do 104 | should eq [ 105 | MonthRange.from_time(Time.new(2006, 8)), 106 | MonthRange.from_time(Time.new(2006, 9)), 107 | MonthRange.from_time(Time.new(2006, 10)), 108 | MonthRange.from_time(Time.new(2006, 11)), 109 | MonthRange.from_time(Time.new(2006, 12)), 110 | MonthRange.from_time(Time.new(2007, 1)), 111 | MonthRange.from_time(Time.new(2007, 2)), 112 | MonthRange.from_time(Time.new(2007, 3)), 113 | MonthRange.from_time(Time.new(2007, 4)), 114 | ] 115 | end 116 | end 117 | end 118 | end 119 | 120 | context 'for "year" range type' do 121 | let(:range_type) { 'year' } 122 | 123 | context 'given the date is "2009-11-23"' do 124 | before { Timecop.freeze(Time.new(2009, 11, 23)) } 125 | 126 | context 'given start is "2007-05-03" and finish is "2008-08-24"' do 127 | let(:start) { '2007-05-03' } 128 | let(:finish) { '2008-08-24' } 129 | 130 | it 'takes notice of start and finish' do 131 | should eq [ 132 | YearRange.from_time(Time.new(2007)), 133 | YearRange.from_time(Time.new(2008)), 134 | ] 135 | end 136 | end 137 | 138 | context 'given the start of business is "2006-07-10"' do 139 | before do 140 | allow(DoubleEntry::Reporting). 141 | to receive_message_chain('configuration.start_of_business'). 142 | and_return(Time.new(2006, 7, 10)) 143 | end 144 | 145 | context 'given start and finish are nil' do 146 | let(:start) { nil } 147 | let(:finish) { nil } 148 | specify do 149 | should eq [ 150 | YearRange.from_time(Time.new(2006)), 151 | YearRange.from_time(Time.new(2007)), 152 | YearRange.from_time(Time.new(2008)), 153 | YearRange.from_time(Time.new(2009)), 154 | ] 155 | end 156 | end 157 | end 158 | end 159 | end 160 | 161 | context 'given an invalid range type "ueue"' do 162 | it 'should raise an error' do 163 | expect { TimeRangeArray.make('ueue') }.to raise_error ArgumentError 164 | end 165 | end 166 | end 167 | end 168 | end 169 | end 170 | -------------------------------------------------------------------------------- /spec/double_entry/reporting/aggregate_array_spec.rb: -------------------------------------------------------------------------------- 1 | module DoubleEntry 2 | module Reporting 3 | RSpec.describe AggregateArray do 4 | let(:user) { create(:user) } 5 | let(:start) { nil } 6 | let(:finish) { nil } 7 | let(:range_type) { 'year' } 8 | let(:function) { 'sum' } 9 | let(:account) { :savings } 10 | let(:transfer_code) { :bonus } 11 | subject(:aggregate_array) do 12 | AggregateArray.new( 13 | function: function, 14 | account: account, 15 | code: transfer_code, 16 | range_type: range_type, 17 | start: start, 18 | finish: finish, 19 | ) 20 | end 21 | 22 | context 'given a deposit was made in 2007 and 2008' do 23 | before do 24 | Timecop.travel(Time.local(2007)) do 25 | perform_deposit user, 10_00 26 | end 27 | Timecop.travel(Time.local(2008)) do 28 | perform_deposit user, 20_00 29 | end 30 | end 31 | 32 | context 'given the date is 2009-03-19' do 33 | before { Timecop.travel(Time.local(2009, 3, 19)) } 34 | 35 | context 'when called with range type of "year"' do 36 | let(:range_type) { 'year' } 37 | let(:start) { '2006-08-03' } 38 | it { should eq [Money.zero, Money.new(10_00), Money.new(20_00), Money.zero] } 39 | 40 | describe 'reuse of aggregates' do 41 | let(:years) { TimeRangeArray.make(range_type, start, finish) } 42 | 43 | context 'and some aggregates were created previously' do 44 | before do 45 | Aggregate.formatted_amount(function: function, account: account, code: transfer_code, range: years[0]) 46 | Aggregate.formatted_amount(function: function, account: account, code: transfer_code, range: years[1]) 47 | allow(Aggregate).to receive(:formatted_amount) 48 | end 49 | 50 | context 'and the transfer code is not provided' do 51 | let(:transfer_code) { nil } 52 | 53 | it 'only asks Aggregate for the non-existent ones' do 54 | expect(Aggregate).not_to receive(:formatted_amount). 55 | with(function: function, account: account, code: transfer_code, 56 | range: years[0], partner_account: nil, filter: nil) 57 | expect(Aggregate).not_to receive(:formatted_amount). 58 | with(function: function, account: account, code: transfer_code, 59 | range: years[1], partner_account: nil, filter: nil) 60 | 61 | expect(Aggregate).to receive(:formatted_amount). 62 | with(function: function, account: account, code: transfer_code, 63 | range: years[2], partner_account: nil, filter: nil) 64 | expect(Aggregate).to receive(:formatted_amount). 65 | with(function: function, account: account, code: transfer_code, 66 | range: years[3], partner_account: nil, filter: nil) 67 | aggregate_array 68 | end 69 | 70 | context 'and the transfer code is provided' do 71 | let(:transfer_code) { :bonus } 72 | 73 | it 'only asks Aggregate for the non-existent ones' do 74 | expect(Aggregate).not_to receive(:formatted_amount). 75 | with(function: function, account: account, code: transfer_code, 76 | range: years[0], partner_account: nil, filter: nil) 77 | expect(Aggregate).not_to receive(:formatted_amount). 78 | with(function: function, account: account, code: transfer_code, 79 | range: years[1], partner_account: nil, filter: nil) 80 | 81 | expect(Aggregate).to receive(:formatted_amount). 82 | with(function: function, account: account, code: transfer_code, 83 | range: years[2], partner_account: nil, filter: nil) 84 | expect(Aggregate).to receive(:formatted_amount). 85 | with(function: function, account: account, code: transfer_code, 86 | range: years[3], partner_account: nil, filter: nil) 87 | aggregate_array 88 | end 89 | end 90 | end 91 | end 92 | end 93 | end 94 | end 95 | end 96 | 97 | context 'given a deposit was made in October and December 2006' do 98 | before do 99 | Timecop.travel(Time.local(2006, 10)) do 100 | perform_deposit user, 10_00 101 | end 102 | Timecop.travel(Time.local(2006, 12)) do 103 | perform_deposit user, 20_00 104 | end 105 | end 106 | 107 | context 'when called with range type of "month", a start of "2006-09-01", and finish of "2007-01-02"' do 108 | let(:range_type) { 'month' } 109 | let(:start) { '2006-09-01' } 110 | let(:finish) { '2007-01-02' } 111 | it { should eq [Money.zero, Money.new(10_00), Money.zero, Money.new(20_00), Money.zero] } 112 | end 113 | 114 | context 'given the date is 2007-02-02' do 115 | before { Timecop.travel(Time.local(2007, 2, 2)) } 116 | 117 | context 'when called with range type of "month"' do 118 | let(:range_type) { 'month' } 119 | let(:start) { '2006-08-03' } 120 | it { should eq [Money.zero, Money.zero, Money.new(10_00), Money.zero, Money.new(20_00), Money.zero, Money.zero] } 121 | end 122 | end 123 | end 124 | 125 | context 'when account is in BTC currency' do 126 | let(:account) { :btc_savings } 127 | let(:range_type) { 'year' } 128 | let(:start) { "#{Time.now.year}-01-01" } 129 | let(:transfer_code) { :btc_test_transfer } 130 | 131 | before do 132 | perform_btc_deposit(user, 100_000_000) 133 | perform_btc_deposit(user, 100_000_000) 134 | end 135 | 136 | it { should eq [Money.new(200_000_000, :btc)] } 137 | end 138 | 139 | context 'when called with range type of "invalid_and_should_not_work"' do 140 | let(:range_type) { 'invalid_and_should_not_work' } 141 | it 'raises an argument error' do 142 | expect { aggregate_array }.to raise_error ArgumentError, "Invalid range type 'invalid_and_should_not_work'" 143 | end 144 | end 145 | 146 | context 'when an invalid function is provided' do 147 | let(:range_type) { 'month' } 148 | let(:start) { '2006-08-03' } 149 | let(:function) { :invalid_function } 150 | it 'raises an AggregateFunctionNotSupported error' do 151 | expect { aggregate_array }.to raise_error AggregateFunctionNotSupported 152 | end 153 | end 154 | end 155 | end 156 | end 157 | -------------------------------------------------------------------------------- /lib/double_entry/reporting.rb: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | require 'double_entry' 3 | require 'double_entry/line' 4 | require 'double_entry/line_metadata' 5 | require 'double_entry/reporting/aggregate' 6 | require 'double_entry/reporting/aggregate_array' 7 | require 'double_entry/reporting/time_range' 8 | require 'double_entry/reporting/day_range' 9 | require 'double_entry/reporting/hour_range' 10 | require 'double_entry/reporting/week_range' 11 | require 'double_entry/reporting/month_range' 12 | require 'double_entry/reporting/year_range' 13 | require 'double_entry/reporting/line_aggregate' 14 | require 'double_entry/reporting/line_aggregate_filter' 15 | require 'double_entry/reporting/line_metadata_filter' 16 | require 'double_entry/reporting/time_range_array' 17 | 18 | module DoubleEntry 19 | module Reporting 20 | include Configurable 21 | extend self 22 | 23 | class Configuration 24 | attr_accessor :start_of_business, :first_month_of_financial_year 25 | 26 | def initialize #:nodoc: 27 | @start_of_business = Time.new(1970, 1, 1) 28 | @first_month_of_financial_year = 7 29 | end 30 | end 31 | 32 | class AggregateFunctionNotSupported < RuntimeError; end 33 | 34 | # Perform an aggregate calculation on a set of transfers for an account. 35 | # 36 | # The transfers included in the calculation can be limited by time range 37 | # and provided custom filters. 38 | # 39 | # @example Find the sum for all $10 :save transfers in all :checking accounts in the current month, made by Australian users (assume the date is January 30, 2014). 40 | # time_range = DoubleEntry::Reporting::TimeRange.make(2014, 1) 41 | # 42 | # DoubleEntry::Line.class_eval do 43 | # scope :specific_transfer_amount, ->(amount) { where(:amount => amount.fractional) } 44 | # end 45 | # 46 | # DoubleEntry::Reporting.aggregate( 47 | # :sum, 48 | # :checking, 49 | # :save, 50 | # time_range, 51 | # :filter => [ 52 | # :scope => { 53 | # :name => :specific_transfer_amount, 54 | # :arguments => [Money.new(10_00)] 55 | # }, 56 | # :metadata => { 57 | # :user_location => 'AU' 58 | # }, 59 | # ] 60 | # ) 61 | # @param [Symbol] function The function to perform on the set of transfers. 62 | # Valid functions are :sum, :count, and :average 63 | # @param [Symbol] account The symbol identifying the account to perform 64 | # the aggregate calculation on. As specified in the account configuration. 65 | # @param [Symbol] code The application specific code for the type of 66 | # transfer to perform an aggregate calculation on. As specified in the 67 | # transfer configuration. 68 | # @param [DoubleEntry::Reporting::TimeRange] range Only include transfers in 69 | # the given time range in the calculation. 70 | # @param [Symbol] partner_account The symbol identifying the partner account 71 | # to perform the aggregate calculatoin on. As specified in the account 72 | # configuration. 73 | # @param [Array>>] filter 74 | # An array of custom filter to apply before performing the aggregate 75 | # calculation. Filters can be either scope filters, where the name must be 76 | # specified, or they can be metadata filters, where the key/value pair to 77 | # match on must be specified. 78 | # Scope filters must be monkey patched as scopes into the DoubleEntry::Line 79 | # class, as the example above shows. Scope filters may also take a list of 80 | # arguments to pass into the monkey patched scope, and, if provided, must 81 | # be contained within an array. 82 | # @return [Money, Integer] Returns a Money object for :sum and :average 83 | # calculations, or a Integer for :count calculations. 84 | # @raise [Reporting::AggregateFunctionNotSupported] The provided function 85 | # is not supported. 86 | # 87 | def aggregate(function:, account:, code:, range:, partner_account: nil, filter: nil) 88 | Aggregate.formatted_amount(function: function, account: account, code: code, range: range, 89 | partner_account: partner_account, filter: filter) 90 | end 91 | 92 | # Perform an aggregate calculation on a set of transfers for an account 93 | # and return the results in an array partitioned by a time range type. 94 | # 95 | # The transfers included in the calculation can be limited by a time range 96 | # and provided custom filters. 97 | # 98 | # @example Find the number of all $10 :save transfers in all :checking accounts per month for the entire year (Assume the year is 2014). 99 | # DoubleEntry::Reporting.aggregate_array( 100 | # :sum, 101 | # :checking, 102 | # :save, 103 | # :range_type => 'month', 104 | # :start => '2014-01-01', 105 | # :finish => '2014-12-31', 106 | # ) 107 | # @param [Symbol] function The function to perform on the set of transfers. 108 | # Valid functions are :sum, :count, and :average 109 | # @param [Symbol] account The symbol identifying the account to perform 110 | # the aggregate calculation on. As specified in the account configuration. 111 | # @param [Symbol] code The application specific code for the type of 112 | # transfer to perform an aggregate calculation on. As specified in the 113 | # transfer configuration. 114 | # @param [Symbol] partner_account The symbol identifying the partner account 115 | # to perform the aggregative calculation on. As specified in the account 116 | # configuration. 117 | # @param [Array, Array>] filter 118 | # A custom filter to apply before performing the aggregate calculation. 119 | # Currently, filters must be monkey patched as scopes into the 120 | # DoubleEntry::Line class in order to be used as filters, as the example 121 | # shows. If the filter requires a parameter, it must be given in a Hash, 122 | # otherwise pass an array with the symbol names for the defined scopes. 123 | # @param [String] range_type The type of time range to return data 124 | # for. For example, specifying 'month' will return an array of the resulting 125 | # aggregate calculation for each month. 126 | # Valid range_types are 'hour', 'day', 'week', 'month', and 'year' 127 | # @param [String] start The start date for the time range to perform 128 | # calculations in. The default start date is the start_of_business (can 129 | # be specified in configuration). 130 | # The format of the string must be as follows: 'YYYY-mm-dd' 131 | # @param [String] finish The finish (or end) date for the time range 132 | # to perform calculations in. The default finish date is the current date. 133 | # The format of the string must be as follows: 'YYYY-mm-dd' 134 | # @return [Array] Returns an array of Money objects for :sum 135 | # and :average calculations, or an array of Integer for :count calculations. 136 | # The array is indexed by the range_type. For example, if range_type is 137 | # specified as 'month', each index in the array will represent a month. 138 | # @raise [Reporting::AggregateFunctionNotSupported] The provided function 139 | # is not supported. 140 | # 141 | def aggregate_array(function:, account:, code:, partner_account: nil, filter: nil, 142 | range_type: nil, start: nil, finish: nil) 143 | AggregateArray.new(function: function, account: account, code: code, partner_account: partner_account, 144 | filter: filter, range_type: range_type, start: start, finish: finish) 145 | end 146 | 147 | private 148 | 149 | delegate :connection, :to => ActiveRecord::Base 150 | delegate :select_values, :to => :connection 151 | 152 | def sanitize_sql_array(sql_array) 153 | ActiveRecord::Base.send(:sanitize_sql_array, sql_array) 154 | end 155 | end 156 | end 157 | -------------------------------------------------------------------------------- /spec/double_entry/reporting_spec.rb: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | RSpec.describe DoubleEntry::Reporting do 3 | describe 'configuration' do 4 | describe 'start_of_business' do 5 | subject(:start_of_business) { DoubleEntry::Reporting.configuration.start_of_business } 6 | 7 | context 'configured to 2011-03-12' do 8 | before do 9 | DoubleEntry::Reporting.configure do |config| 10 | config.start_of_business = Time.new(2011, 3, 12) 11 | end 12 | end 13 | 14 | it { should eq Time.new(2011, 3, 12) } 15 | end 16 | 17 | context 'not configured' do 18 | it { should eq Time.new(1970, 1, 1) } 19 | end 20 | end 21 | end 22 | 23 | describe '.aggregate' do 24 | before do 25 | # get rid of "helpful" predefined config 26 | @config_accounts = DoubleEntry.configuration.accounts 27 | @config_transfers = DoubleEntry.configuration.transfers 28 | DoubleEntry.configuration.accounts = DoubleEntry::Account::Set.new 29 | DoubleEntry.configuration.transfers = DoubleEntry::Transfer::Set.new 30 | 31 | DoubleEntry.configure do |config| 32 | config.define_accounts do |accounts| 33 | accounts.define(:identifier => :savings) 34 | accounts.define(:identifier => :cash) 35 | accounts.define(:identifier => :credit) 36 | accounts.define(:identifier => :account_fees) 37 | accounts.define(:identifier => :service_fees) 38 | end 39 | 40 | config.define_transfers do |transfers| 41 | transfers.define(:from => :savings, :to => :cash, :code => :spend) 42 | transfers.define(:from => :cash, :to => :savings, :code => :save) 43 | transfers.define(:from => :cash, :to => :credit, :code => :bill) 44 | transfers.define(:from => :savings, :to => :account_fees, :code => :fees) 45 | transfers.define(:from => :savings, :to => :service_fees, :code => :fees) 46 | end 47 | end 48 | 49 | cash = DoubleEntry.account(:cash) 50 | savings = DoubleEntry.account(:savings) 51 | credit = DoubleEntry.account(:credit) 52 | service_fees = DoubleEntry.account(:service_fees) 53 | account_fees = DoubleEntry.account(:account_fees) 54 | DoubleEntry.transfer(Money.new(10_00), :from => cash, :to => savings, :code => :save, :metadata => { :reason => 'payday', :save_for => 'cats' }) 55 | DoubleEntry.transfer(Money.new(10_00), :from => cash, :to => savings, :code => :save, :metadata => { :reason => 'payday' }) 56 | DoubleEntry.transfer(Money.new(11_00), :from => cash, :to => savings, :code => :save, :metadata => { :reason => 'payday', :save_for => 'dogs' }) 57 | DoubleEntry.transfer(Money.new(20_00), :from => cash, :to => savings, :code => :save) 58 | DoubleEntry.transfer(Money.new(20_00), :from => cash, :to => savings, :code => :save) 59 | DoubleEntry.transfer(Money.new(30_00), :from => cash, :to => credit, :code => :bill) 60 | DoubleEntry.transfer(Money.new(40_00), :from => cash, :to => credit, :code => :bill) 61 | DoubleEntry.transfer(Money.new(50_00), :from => savings, :to => cash, :code => :spend) 62 | DoubleEntry.transfer(Money.new(60_00), :from => savings, :to => cash, :code => :spend, :metadata => { :category => ['entertainment', 'training'] }) 63 | DoubleEntry.transfer(Money.new(70_00), :from => savings, :to => service_fees, :code => :fees) 64 | DoubleEntry.transfer(Money.new(80_00), :from => savings, :to => account_fees, :code => :fees) 65 | end 66 | 67 | after do 68 | # restore "helpful" predefined config 69 | DoubleEntry.configuration.accounts = @config_accounts 70 | DoubleEntry.configuration.transfers = @config_transfers 71 | end 72 | 73 | describe 'filter solely on transaction identifiers and time' do 74 | let(:function) { :sum } 75 | let(:account) { :savings } 76 | let(:code) { :save } 77 | let(:range) { DoubleEntry::Reporting::MonthRange.current } 78 | 79 | subject(:aggregate) do 80 | DoubleEntry::Reporting.aggregate(function: function, account: account, code: code, range: range) 81 | end 82 | 83 | specify 'Total attempted to save' do 84 | expect(aggregate).to eq(Money.new(71_00)) 85 | end 86 | end 87 | 88 | describe 'filter by named scope that does not take arguments' do 89 | let(:function) { :sum } 90 | let(:account) { :savings } 91 | let(:code) { :save } 92 | let(:range) { DoubleEntry::Reporting::MonthRange.current } 93 | 94 | subject(:aggregate) do 95 | DoubleEntry::Reporting.aggregate( 96 | function: function, 97 | account: account, 98 | code: code, 99 | range: range, 100 | filter: [ 101 | :scope => { 102 | :name => :ten_dollar_transfers, 103 | }, 104 | ] 105 | ) 106 | end 107 | 108 | before do 109 | DoubleEntry::Line.class_eval do 110 | scope :ten_dollar_transfers, -> { where(:amount => Money.new(10_00).fractional) } 111 | end 112 | end 113 | 114 | specify 'Total amount of $10 transfers attempted to save' do 115 | expect(aggregate).to eq(Money.new(20_00)) 116 | end 117 | end 118 | 119 | describe 'filter by named scope that takes arguments' do 120 | let(:function) { :sum } 121 | let(:account) { :savings } 122 | let(:code) { :save } 123 | let(:range) { DoubleEntry::Reporting::MonthRange.current } 124 | 125 | subject(:aggregate) do 126 | DoubleEntry::Reporting.aggregate( 127 | function: function, 128 | account: account, 129 | code: code, 130 | range: range, 131 | filter: [ 132 | :scope => { 133 | :name => :specific_transfer_amount, 134 | :arguments => [Money.new(20_00)], 135 | }, 136 | ] 137 | ) 138 | end 139 | 140 | before do 141 | DoubleEntry::Line.class_eval do 142 | scope :specific_transfer_amount, ->(amount) { where(:amount => amount.fractional) } 143 | end 144 | end 145 | 146 | specify 'Total amount of transfers of $20 attempted to save' do 147 | expect(aggregate).to eq(Money.new(40_00)) 148 | end 149 | end 150 | 151 | describe 'filter by metadata' do 152 | let(:function) { :sum } 153 | let(:account) { :savings } 154 | let(:code) { :save } 155 | let(:range) { DoubleEntry::Reporting::MonthRange.current } 156 | 157 | subject(:aggregate) do 158 | DoubleEntry::Reporting.aggregate( 159 | function: function, 160 | account: account, 161 | code: code, 162 | range: range, 163 | filter: filter, 164 | ) 165 | end 166 | 167 | context 'filtering by a single metadata key/value pair' do 168 | let(:filter) do 169 | [ 170 | :metadata => { 171 | :reason => 'payday', 172 | }, 173 | ] 174 | end 175 | 176 | specify 'Total amount of transfers saved because payday' do 177 | expect(aggregate).to eq(Money.new(31_00)) 178 | end 179 | end 180 | 181 | context 'filtering by key with multiple values' do 182 | let(:code) { :spend } 183 | let(:filter) do 184 | [ 185 | :metadata => { 186 | :category => category, 187 | }, 188 | ] 189 | end 190 | 191 | context 'filter by category entertainment' do 192 | let(:category) { 'entertainment' } 193 | 194 | specify 'Total amount of transfers spent because entertainment' do 195 | expect(aggregate).to eq(-Money.new(60_00)) 196 | end 197 | end 198 | 199 | context 'filter by category training' do 200 | let(:category) { 'training' } 201 | 202 | specify 'Total amount of transfers spent because training' do 203 | expect(aggregate).to eq(-Money.new(60_00)) 204 | end 205 | end 206 | 207 | end 208 | 209 | context 'filtering by multiple metadata key/value pairs' do 210 | let(:filter) do 211 | [ 212 | :metadata => { 213 | :reason => 'payday', 214 | :save_for => 'dogs', 215 | }, 216 | ] 217 | end 218 | 219 | specify 'Total amount of transfers saved for dogs because payday' do 220 | expect(aggregate).to eq(Money.new(11_00)) 221 | end 222 | end 223 | end 224 | 225 | describe 'filter by partner_account' do 226 | let(:function) { :sum } 227 | let(:account) { :savings } 228 | let(:code) { :fees } 229 | let(:range) { DoubleEntry::Reporting::MonthRange.current } 230 | let(:partner_account) { :service_fees } 231 | subject(:aggregate) do 232 | DoubleEntry::Reporting.aggregate( 233 | function: function, 234 | account: account, 235 | code: code, 236 | range: range, 237 | partner_account: partner_account 238 | ) 239 | end 240 | 241 | specify 'Total amount of service fees paid' do 242 | expect(aggregate).to eq(Money.new(-70_00)) 243 | end 244 | end 245 | end 246 | 247 | describe '.aggregate_array' do 248 | before do 249 | # get rid of "helpful" predefined config 250 | @config_accounts = DoubleEntry.configuration.accounts 251 | @config_transfers = DoubleEntry.configuration.transfers 252 | DoubleEntry.configuration.accounts = DoubleEntry::Account::Set.new 253 | DoubleEntry.configuration.transfers = DoubleEntry::Transfer::Set.new 254 | 255 | DoubleEntry.configure do |config| 256 | config.define_accounts do |accounts| 257 | accounts.define(:identifier => :savings) 258 | accounts.define(:identifier => :account_fees) 259 | accounts.define(:identifier => :service_fees) 260 | end 261 | 262 | config.define_transfers do |transfers| 263 | transfers.define(:from => :savings, :to => :account_fees, :code => :fees) 264 | transfers.define(:from => :savings, :to => :service_fees, :code => :fees) 265 | end 266 | end 267 | 268 | savings = DoubleEntry.account(:savings) 269 | service_fees = DoubleEntry.account(:service_fees) 270 | account_fees = DoubleEntry.account(:account_fees) 271 | 272 | Timecop.travel(Time.local(2015, 11)) do 273 | DoubleEntry.transfer(Money.new(50_00), :from => savings, :to => service_fees, :code => :fees) 274 | DoubleEntry.transfer(Money.new(60_00), :from => savings, :to => account_fees, :code => :fees) 275 | end 276 | 277 | Timecop.travel(Time.local(2015, 12)) do 278 | DoubleEntry.transfer(Money.new(70_00), :from => savings, :to => service_fees, :code => :fees) 279 | DoubleEntry.transfer(Money.new(80_00), :from => savings, :to => account_fees, :code => :fees) 280 | end 281 | end 282 | 283 | after do 284 | # restore "helpful" predefined config 285 | DoubleEntry.configuration.accounts = @config_accounts 286 | DoubleEntry.configuration.transfers = @config_transfers 287 | end 288 | 289 | describe 'filter solely on transaction identifiers and time' do 290 | let(:function) { :sum } 291 | let(:account) { :savings } 292 | let(:code) { :fees } 293 | subject(:aggregate) do 294 | DoubleEntry::Reporting.aggregate_array( 295 | function: function, 296 | account: account, 297 | code: code, 298 | range_type: 'year', 299 | start: '2015-01-01' 300 | ) 301 | end 302 | 303 | before do 304 | Timecop.travel(Time.local(2016,01,01)) 305 | end 306 | 307 | it { is_expected.to eq [Money.new(-260_00), Money.zero] } 308 | end 309 | 310 | describe 'filter by partner_account' do 311 | let(:function) { :sum } 312 | let(:account) { :savings } 313 | let(:code) { :fees } 314 | let(:start) { '2015-01-01' } 315 | let(:range_type) { 'year' } 316 | let(:partner_account) { :service_fees } 317 | subject(:aggregate) do 318 | DoubleEntry::Reporting.aggregate_array( 319 | function: function, 320 | account: account, 321 | code: code, 322 | partner_account: partner_account, 323 | range_type: range_type, 324 | start: start, 325 | ) 326 | end 327 | 328 | before do 329 | Timecop.travel(Time.local(2016,01,01)) 330 | end 331 | 332 | it { is_expected.to eq [Money.new(-120_00), Money.zero] } 333 | end 334 | end 335 | end 336 | -------------------------------------------------------------------------------- /spec/double_entry/reporting/aggregate_spec.rb: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | module DoubleEntry 3 | module Reporting 4 | RSpec.describe Aggregate do 5 | let(:user) { create(:user) } 6 | let(:expected_weekly_average) do 7 | (Money.new(20_00) + Money.new(40_00) + Money.new(50_00)) / 3 8 | end 9 | let(:expected_monthly_average) do 10 | (Money.new(20_00) + Money.new(40_00) + Money.new(50_00) + Money.new(40_00) + Money.new(50_00)) / 5 11 | end 12 | 13 | before do 14 | # Thursday 15 | Timecop.freeze Time.local(2009, 10, 1) do 16 | perform_deposit(user, 20_00) 17 | end 18 | 19 | # Saturday 20 | Timecop.freeze Time.local(2009, 10, 3) do 21 | perform_deposit(user, 40_00) 22 | end 23 | 24 | Timecop.freeze Time.local(2009, 10, 10) do 25 | perform_deposit(user, 50_00) 26 | end 27 | 28 | Timecop.freeze Time.local(2009, 11, 1, 0, 59, 0) do 29 | perform_deposit(user, 40_00) 30 | end 31 | 32 | Timecop.freeze Time.local(2009, 11, 1, 1, 00, 0) do 33 | perform_deposit(user, 50_00) 34 | end 35 | 36 | allow(LineAggregate).to receive(:aggregate).and_call_original 37 | end 38 | 39 | it 'should store the aggregate for quick retrieval' do 40 | Aggregate.new(function: :sum, account: :savings, code: :bonus, range: TimeRange.make(:year => 2009, :month => 10)).amount 41 | expect(LineAggregate.count).to eq 1 42 | end 43 | 44 | describe 'partner_account aggregates' do 45 | context 'when transfers exist with the same account and code, but different partner_account' do 46 | before do 47 | Timecop.freeze Time.local(2009, 10, 1) do 48 | transfer_deposit_fee(user, 1_00) 49 | transfer_account_fee(user, 1_00) 50 | end 51 | 52 | Timecop.freeze Time.local(2009, 10, 5) do 53 | transfer_deposit_fee(user, 1_00) 54 | end 55 | 56 | Timecop.freeze Time.local(2009, 11, 1) do 57 | transfer_deposit_fee(user, 2_00) 58 | transfer_account_fee(user, 1_00) 59 | end 60 | end 61 | 62 | context 'when the partner_account is supplied' do 63 | it 'calculates the complete year correctly for deposit fees' do 64 | amount = Aggregate.new(function: :sum, account: :savings, code: :fee, range: TimeRange.make(:year => 2009), partner_account: :deposit_fees).formatted_amount 65 | expect(amount).to eq (Money.new(-4_00)) 66 | end 67 | 68 | it 'calculates the complete year correctly for account fees' do 69 | amount = Aggregate.new(function: :sum, account: :savings, code: :fee, range: TimeRange.make(:year => 2009), partner_account: :account_fees).formatted_amount 70 | expect(amount).to eq (Money.new(-2_00)) 71 | end 72 | end 73 | 74 | context 'when the partner_account is not supplied' do 75 | it 'calculates the complete year correctly for all fees' do 76 | amount = Aggregate.new(function: :sum, account: :savings, code: :fee, range: TimeRange.make(:year => 2009)).formatted_amount 77 | expect(amount).to eq (Money.new(-6_00)) 78 | end 79 | end 80 | end 81 | 82 | it 'calculates a new aggregate when partner_account is specified' do 83 | Aggregate.new(function: :sum, account: :savings, code: :bonus, range: TimeRange.make(:year => 2009, :month => 9)).amount 84 | Aggregate.new(function: :sum, account: :savings, code: :bonus, range: TimeRange.make(:year => 2009, :month => 9), partner_account: :test).amount 85 | Aggregate.new(function: :sum, account: :savings, code: :bonus, range: TimeRange.make(:year => 2009, :month => 10)).amount 86 | expect(LineAggregate.count).to eq 3 87 | expect(LineAggregate).to have_received(:aggregate).exactly(3).times 88 | end 89 | 90 | it "only stores an aggregate including partner_account once if it's requested more than once" do 91 | Aggregate.new(function: :sum, account: :savings, code: :bonus, range: TimeRange.make(:year => 2009, :month => 9), partner_account: :test).amount 92 | Aggregate.new(function: :sum, account: :savings, code: :bonus, range: TimeRange.make(:year => 2009, :month => 9), partner_account: :test).amount 93 | Aggregate.new(function: :sum, account: :savings, code: :bonus, range: TimeRange.make(:year => 2009, :month => 10), partner_account: :test).amount 94 | expect(LineAggregate.count).to eq 2 95 | expect(LineAggregate).to have_received(:aggregate).twice 96 | end 97 | end 98 | 99 | it 'only stores the aggregate once if it is requested more than once' do 100 | Aggregate.new(function: :sum, account: :savings, code: :bonus, range: TimeRange.make(:year => 2009, :month => 9)).amount 101 | Aggregate.new(function: :sum, account: :savings, code: :bonus, range: TimeRange.make(:year => 2009, :month => 9)).amount 102 | Aggregate.new(function: :sum, account: :savings, code: :bonus, range: TimeRange.make(:year => 2009, :month => 10)).amount 103 | expect(LineAggregate.count).to eq 2 104 | expect(LineAggregate).to have_received(:aggregate).twice 105 | end 106 | 107 | it 'calculates the complete year correctly' do 108 | amount = Aggregate.new(function: :sum, account: :savings, code: :bonus, range: TimeRange.make(:year => 2009)).formatted_amount 109 | expect(amount).to eq Money.new(200_00) 110 | end 111 | 112 | it 'calculates seperate months correctly' do 113 | amount = Aggregate.new(function: :sum, account: :savings, code: :bonus, range: TimeRange.make(:year => 2009, :month => 10)).formatted_amount 114 | expect(amount).to eq Money.new(110_00) 115 | 116 | amount = Aggregate.new(function: :sum, account: :savings, code: :bonus, range: TimeRange.make(:year => 2009, :month => 11)).formatted_amount 117 | expect(amount).to eq Money.new(90_00) 118 | end 119 | 120 | it 'calculates separate weeks correctly' do 121 | # Week 40 - Mon Sep 28, 2009 to Sun Oct 4 2009 122 | amount = Aggregate.new(function: :sum, account: :savings, code: :bonus, range: TimeRange.make(:year => 2009, :week => 40)).formatted_amount 123 | expect(amount).to eq Money.new(60_00) 124 | end 125 | 126 | it 'calculates separate days correctly' do 127 | # 1 Nov 2009 128 | amount = Aggregate.new(function: :sum, account: :savings, code: :bonus, range: TimeRange.make(:year => 2009, :week => 44, :day => 7)).formatted_amount 129 | expect(amount).to eq Money.new(90_00) 130 | end 131 | 132 | it 'calculates separate hours correctly' do 133 | # 1 Nov 2009 134 | amount = Aggregate.new(function: :sum, account: :savings, code: :bonus, range: TimeRange.make(:year => 2009, :week => 44, :day => 7, :hour => 0)).formatted_amount 135 | expect(amount).to eq Money.new(40_00) 136 | amount = Aggregate.new(function: :sum, account: :savings, code: :bonus, range: TimeRange.make(:year => 2009, :week => 44, :day => 7, :hour => 1)).formatted_amount 137 | expect(amount).to eq Money.new(50_00) 138 | end 139 | 140 | it 'calculates, but not store aggregates when the time range is still current' do 141 | Timecop.freeze Time.local(2009, 11, 21) do 142 | amount = Aggregate.new(function: :sum, account: :savings, code: :bonus, range: TimeRange.make(:year => 2009, :month => 11)).formatted_amount 143 | expect(amount).to eq Money.new(90_00) 144 | expect(LineAggregate.count).to eq 0 145 | end 146 | end 147 | 148 | it 'calculates, but not store aggregates when the time range is in the future' do 149 | Timecop.freeze Time.local(2009, 11, 21) do 150 | amount = Aggregate.new(function: :sum, account: :savings, code: :bonus, range: TimeRange.make(:year => 2009, :month => 12)).formatted_amount 151 | expect(amount).to eq Money.new(0) 152 | expect(LineAggregate.count).to eq 0 153 | end 154 | end 155 | 156 | it 'calculates monthly all_time ranges correctly' do 157 | amount = Aggregate.new(function: :sum, account: :savings, code: :bonus, range: TimeRange.make(:year => 2009, :month => 12, :range_type => :all_time)).formatted_amount 158 | expect(amount).to eq Money.new(200_00) 159 | end 160 | 161 | it 'calculates the average monthly all_time ranges correctly' do 162 | amount = Aggregate.new(function: :average, account: :savings, code: :bonus, range: TimeRange.make(:year => 2009, :month => 12, :range_type => :all_time)).formatted_amount 163 | expect(amount).to eq expected_monthly_average 164 | end 165 | 166 | it 'returns the correct count for weekly all_time ranges correctly' do 167 | amount = Aggregate.new(function: :count, account: :savings, code: :bonus, range: TimeRange.make(:year => 2009, :month => 12, :range_type => :all_time)).formatted_amount 168 | expect(amount).to eq 5 169 | end 170 | 171 | it 'calculates weekly all_time ranges correctly' do 172 | amount = Aggregate.new(function: :sum, account: :savings, code: :bonus, range: TimeRange.make(:year => 2009, :week => 43, :range_type => :all_time)).formatted_amount 173 | expect(amount).to eq Money.new(110_00) 174 | end 175 | 176 | it 'calculates the average weekly all_time ranges correctly' do 177 | amount = Aggregate.new(function: :average, account: :savings, code: :bonus, range: TimeRange.make(:year => 2009, :week => 43, :range_type => :all_time)).formatted_amount 178 | expect(amount).to eq expected_weekly_average 179 | end 180 | 181 | it 'returns the correct count for weekly all_time ranges correctly' do 182 | amount = Aggregate.new(function: :count, account: :savings, code: :bonus, range: TimeRange.make(:year => 2009, :week => 43, :range_type => :all_time)).formatted_amount 183 | expect(amount).to eq 3 184 | end 185 | 186 | it 'raises an AggregateFunctionNotSupported exception' do 187 | expect do 188 | Aggregate.new(function: 189 | :not_supported_calculation,account: :savings, code: :bonus, range: TimeRange.make(:year => 2009, :week => 43, :range_type => :all_time) 190 | ).amount 191 | end.to raise_error(AggregateFunctionNotSupported) 192 | end 193 | 194 | context 'filters' do 195 | let(:range) { TimeRange.make(:year => 2011, :month => 10) } 196 | let(:filter) do 197 | [ 198 | :scope => { 199 | :name => :test_filter, 200 | }, 201 | ] 202 | end 203 | 204 | DoubleEntry::Line.class_eval do 205 | scope :test_filter, -> { where(:amount => 10_00) } 206 | end 207 | 208 | before do 209 | Timecop.freeze Time.local(2011, 10, 10) do 210 | perform_deposit user, 10_00 211 | end 212 | 213 | Timecop.freeze Time.local(2011, 10, 10) do 214 | perform_deposit user, 9_00 215 | end 216 | end 217 | 218 | it 'saves filtered aggregations' do 219 | expect do 220 | Aggregate.new(function: :sum, account: :savings, code: :bonus, range: range, :filter => filter).amount 221 | end.to change { LineAggregate.count }.by 1 222 | end 223 | 224 | it 'saves filtered aggregation only once for a range' do 225 | expect do 226 | Aggregate.new(function: :sum, account: :savings, code: :bonus, range: range, :filter => filter).amount 227 | Aggregate.new(function: :sum, account: :savings, code: :bonus, range: range, :filter => filter).amount 228 | end.to change { LineAggregate.count }.by 1 229 | end 230 | 231 | it 'saves filtered aggregations and non filtered aggregations separately' do 232 | expect do 233 | Aggregate.new(function: :sum, account: :savings, code: :bonus, range: range, :filter => filter).amount 234 | Aggregate.new(function: :sum, account: :savings, code: :bonus, range: range).amount 235 | end.to change { LineAggregate.count }.by 2 236 | end 237 | 238 | it 'loads the correct saved aggregation' do 239 | # cache the results for filtered and unfiltered aggregations 240 | Aggregate.new(function: :sum, account: :savings, code: :bonus, range: range, :filter => filter).amount 241 | Aggregate.new(function: :sum, account: :savings, code: :bonus, range: range).amount 242 | 243 | # ensure a second call loads the correct cached value 244 | amount = Aggregate.new(function: :sum, account: :savings, code: :bonus, range: range, :filter => filter).formatted_amount 245 | expect(amount).to eq Money.new(10_00) 246 | 247 | amount = Aggregate.new(function: :sum, account: :savings, code: :bonus, range: range).formatted_amount 248 | expect(amount).to eq Money.new(19_00) 249 | end 250 | end 251 | end 252 | RSpec.describe Aggregate, 'currencies' do 253 | let(:user) { create(:user) } 254 | before do 255 | perform_btc_deposit(user, 100_000_000) 256 | perform_btc_deposit(user, 200_000_000) 257 | end 258 | 259 | it 'calculates the sum in the correct currency' do 260 | amount = Aggregate.new(function: :sum, account: :btc_savings, code: :btc_test_transfer, range: TimeRange.make(:year => Time.now.year)).formatted_amount 261 | expect(amount).to eq(Money.new(300_000_000, :btc)) 262 | end 263 | end 264 | end 265 | end 266 | --------------------------------------------------------------------------------