├── public
├── favicon.ico
├── blank_iframe.html
├── images
│ ├── success.png
│ ├── success16.png
│ └── calendar_date_select
├── javascripts
│ ├── calendar_date_select
│ ├── application.js
│ ├── filters.js
│ ├── tags.js
│ ├── buckets.js
│ ├── money.js
│ ├── statements.js
│ └── accounts.js
├── stylesheets
│ └── calendar_date_select
├── robots.txt
├── 422.html
├── 404.html
└── 500.html
├── vendor
├── .gitignore
└── plugins
│ ├── cached_externals
│ ├── .gitignore
│ ├── lib
│ │ ├── cached_externals
│ │ │ └── git-hooks
│ │ │ │ ├── post-merge
│ │ │ │ └── post-checkout
│ │ └── tasks
│ │ │ └── git.rake
│ ├── recipes
│ │ └── cached_externals.rb
│ ├── cached_externals.gemspec
│ ├── LICENSE
│ └── README.rdoc
│ └── .gitignore
├── .gitignore
├── app
├── helpers
│ ├── sessions_helper.rb
│ ├── tagged_items_helper.rb
│ ├── tags_helper.rb
│ ├── statements_helper.rb
│ ├── buckets_helper.rb
│ ├── accounts_helper.rb
│ ├── application_helper.rb
│ └── subscriptions_helper.rb
├── views
│ ├── tags
│ │ ├── update.js.rjs
│ │ ├── _cloud.html.haml
│ │ ├── _name.html.haml
│ │ ├── show.html.haml
│ │ └── _delete_form.html.haml
│ ├── events
│ │ ├── show.js.rjs
│ │ ├── _event.html.haml
│ │ ├── update.js.rjs
│ │ ├── edit.html.haml
│ │ ├── _reallocation_item.html.haml
│ │ ├── new.html.haml
│ │ ├── _line_item.html.haml
│ │ ├── _balance.html.haml
│ │ ├── _tagged_item.html.haml
│ │ ├── create.js.rjs
│ │ ├── _expand.html.haml
│ │ ├── _form_reallocate.html.haml
│ │ ├── destroy.js.rjs
│ │ ├── _row.html.haml
│ │ ├── _form_tags.html.haml
│ │ ├── _form.html.haml
│ │ ├── _form_section.html.haml
│ │ └── _form_general.html.haml
│ ├── accounts
│ │ ├── new.html.haml
│ │ ├── update.js.rjs
│ │ ├── show.html.haml
│ │ ├── _account.html.haml
│ │ ├── _name.html.haml
│ │ └── _form.html.haml
│ ├── line_items
│ │ └── _line_item.html.haml
│ ├── tagged_items
│ │ └── _tagged_item.html.haml
│ ├── account_items
│ │ └── _account_item.html.haml
│ ├── subscriptions
│ │ ├── index.html.haml
│ │ ├── _blank_slate.html.haml
│ │ └── show.html.haml
│ ├── statements
│ │ ├── _subtotal.html.haml
│ │ ├── show.html.haml
│ │ ├── index.html.haml
│ │ ├── _uncleared.html.haml
│ │ ├── new.html.haml
│ │ └── edit.html.haml
│ ├── buckets
│ │ ├── update.js.rjs
│ │ ├── _bucket_name_for_perma.html.haml
│ │ ├── _bucket.html.haml
│ │ ├── _bucket_name_for_index.html.haml
│ │ ├── _delete_form.html.haml
│ │ ├── show.html.haml
│ │ └── index.html.haml
│ ├── sessions
│ │ └── new.html.haml
│ └── layouts
│ │ └── application.html.haml
├── models
│ ├── user_subscription.rb
│ ├── actor.rb
│ ├── tag.rb
│ ├── user.rb
│ ├── tagged_item.rb
│ ├── line_item.rb
│ ├── account_item.rb
│ ├── query_filter.rb
│ ├── statement.rb
│ ├── bucket.rb
│ ├── subscription.rb
│ └── account.rb
├── concerns
│ ├── categorized_items.rb
│ ├── pageable.rb
│ └── option_handler.rb
└── controllers
│ ├── subscriptions_controller.rb
│ ├── sessions_controller.rb
│ ├── tagged_items_controller.rb
│ ├── statements_controller.rb
│ ├── application_controller.rb
│ ├── accounts_controller.rb
│ ├── tags_controller.rb
│ ├── buckets_controller.rb
│ └── events_controller.rb
├── LICENSE
├── Capfile
├── script
├── about
├── plugin
├── runner
├── server
├── console
├── destroy
├── generate
├── performance
│ ├── profiler
│ ├── request
│ └── benchmarker
└── find
├── test
├── fixtures
│ ├── subscriptions.yml
│ ├── user_subscriptions.yml
│ ├── tagged_items.yml
│ ├── users.yml
│ ├── tags.yml
│ ├── statements.yml
│ ├── accounts.yml
│ ├── actors.yml
│ ├── account_items.yml
│ ├── buckets.yml
│ ├── events.yml
│ └── line_items.yml
├── unit
│ ├── tagged_item_test.rb
│ ├── account_item_test.rb
│ ├── line_item_test.rb
│ ├── tag_test.rb
│ ├── user_test.rb
│ ├── bucket_test.rb
│ └── statement_test.rb
├── test_helper.rb
└── functional
│ ├── sessions_controller_test.rb
│ ├── subscriptions_controller_test.rb
│ ├── tagged_items_controller_test.rb
│ ├── statements_controller_test.rb
│ ├── buckets_controller_test.rb
│ └── accounts_controller_test.rb
├── config
├── initializers
│ ├── calendar_date_select.rb
│ ├── activerecord.rb
│ ├── redirect_log.rb
│ └── new_rails_defaults.rb
├── session.yml
├── deploy.rb
├── database.yml
├── routes.rb
├── environment.rb
├── externals.yml
├── environments
│ ├── development.rb
│ ├── production.rb
│ └── test.rb
└── boot.rb
├── config.ru
├── lib
├── bucket_wise
│ └── version.rb
├── tasks
│ ├── subscription.rake
│ ├── data.rake
│ └── user.rake
└── populator.rb
├── db
└── migrate
│ ├── 20090330132556_add_memo_field_to_event.rb
│ ├── 20090516072907_add_limit_to_accounts.rb
│ ├── 20090404154634_cache_balances_on_buckets_and_accounts.rb
│ ├── 20090421221109_add_statements.rb
│ ├── 20090506161959_normalize_actors.rb
│ └── 20080513032848_initial_schema.rb
├── Rakefile
├── CHANGELOG.rdoc
├── TODO
└── README.rdoc
/public/favicon.ico:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/vendor/.gitignore:
--------------------------------------------------------------------------------
1 | rails
2 |
--------------------------------------------------------------------------------
/vendor/plugins/cached_externals/.gitignore:
--------------------------------------------------------------------------------
1 | *.swp
2 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | log
2 | tmp
3 | db/*.sqlite3
4 | db/backup
5 | *.swp
6 |
--------------------------------------------------------------------------------
/app/helpers/sessions_helper.rb:
--------------------------------------------------------------------------------
1 | module SessionsHelper
2 | end
3 |
--------------------------------------------------------------------------------
/app/helpers/tagged_items_helper.rb:
--------------------------------------------------------------------------------
1 | module TaggedItemsHelper
2 | end
3 |
--------------------------------------------------------------------------------
/app/views/tags/update.js.rjs:
--------------------------------------------------------------------------------
1 | page[:name].replace_html :partial => "tags/name"
2 |
--------------------------------------------------------------------------------
/public/blank_iframe.html:
--------------------------------------------------------------------------------
1 | ../vendor/plugins/calendar_date_select/public/blank_iframe.html
--------------------------------------------------------------------------------
/public/images/success.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jamis/bucketwise/HEAD/public/images/success.png
--------------------------------------------------------------------------------
/vendor/plugins/.gitignore:
--------------------------------------------------------------------------------
1 | calendar_date_select
2 | haml
3 | project_search
4 | safe_mass_assignment
5 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | This software is hereby placed in the public domain.
2 | - Jamis Buck (author), April 2009
3 |
--------------------------------------------------------------------------------
/public/images/success16.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jamis/bucketwise/HEAD/public/images/success16.png
--------------------------------------------------------------------------------
/public/images/calendar_date_select:
--------------------------------------------------------------------------------
1 | ../../vendor/plugins/calendar_date_select/public/images/calendar_date_select
--------------------------------------------------------------------------------
/Capfile:
--------------------------------------------------------------------------------
1 | load 'deploy'
2 | Dir['vendor/plugins/*/recipes/*.rb'].each { |plugin| load(plugin) }
3 | load 'config/deploy'
4 |
--------------------------------------------------------------------------------
/public/javascripts/calendar_date_select:
--------------------------------------------------------------------------------
1 | ../../vendor/plugins/calendar_date_select/public/javascripts/calendar_date_select
--------------------------------------------------------------------------------
/public/stylesheets/calendar_date_select:
--------------------------------------------------------------------------------
1 | ../../vendor/plugins/calendar_date_select/public/stylesheets/calendar_date_select
--------------------------------------------------------------------------------
/script/about:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env ruby
2 | require File.dirname(__FILE__) + '/../config/boot'
3 | require 'commands/about'
4 |
--------------------------------------------------------------------------------
/script/plugin:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env ruby
2 | require File.dirname(__FILE__) + '/../config/boot'
3 | require 'commands/plugin'
4 |
--------------------------------------------------------------------------------
/script/runner:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env ruby
2 | require File.dirname(__FILE__) + '/../config/boot'
3 | require 'commands/runner'
4 |
--------------------------------------------------------------------------------
/script/server:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env ruby
2 | require File.dirname(__FILE__) + '/../config/boot'
3 | require 'commands/server'
4 |
--------------------------------------------------------------------------------
/script/console:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env ruby
2 | require File.dirname(__FILE__) + '/../config/boot'
3 | require 'commands/console'
4 |
--------------------------------------------------------------------------------
/script/destroy:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env ruby
2 | require File.dirname(__FILE__) + '/../config/boot'
3 | require 'commands/destroy'
4 |
--------------------------------------------------------------------------------
/script/generate:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env ruby
2 | require File.dirname(__FILE__) + '/../config/boot'
3 | require 'commands/generate'
4 |
--------------------------------------------------------------------------------
/test/fixtures/subscriptions.yml:
--------------------------------------------------------------------------------
1 | john:
2 | owner: john
3 |
4 | tim:
5 | owner: tim
6 |
7 | john_family:
8 | owner: john
9 |
--------------------------------------------------------------------------------
/app/models/user_subscription.rb:
--------------------------------------------------------------------------------
1 | class UserSubscription < ActiveRecord::Base
2 | belongs_to :subscription
3 | belongs_to :user
4 | end
--------------------------------------------------------------------------------
/app/views/events/show.js.rjs:
--------------------------------------------------------------------------------
1 | page.events.expanded(event.id)
2 | page.insert_html :after, dom_id(event), :partial => "events/expand"
3 |
--------------------------------------------------------------------------------
/app/views/tags/_cloud.html.haml:
--------------------------------------------------------------------------------
1 | #cloud
2 | - subscription.tags.sort_by(&:name).each do |item|
3 | = link_to(item.name, tag_path(item))
4 |
--------------------------------------------------------------------------------
/app/views/events/_event.html.haml:
--------------------------------------------------------------------------------
1 | = render :partial => "events/row", :locals => { :event => event, :amount => event.balance, :account_links => true }
2 |
--------------------------------------------------------------------------------
/script/performance/profiler:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env ruby
2 | require File.dirname(__FILE__) + '/../../config/boot'
3 | require 'commands/performance/profiler'
4 |
--------------------------------------------------------------------------------
/script/performance/request:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env ruby
2 | require File.dirname(__FILE__) + '/../../config/boot'
3 | require 'commands/performance/request'
4 |
--------------------------------------------------------------------------------
/script/performance/benchmarker:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env ruby
2 | require File.dirname(__FILE__) + '/../../config/boot'
3 | require 'commands/performance/benchmarker'
4 |
--------------------------------------------------------------------------------
/config/initializers/calendar_date_select.rb:
--------------------------------------------------------------------------------
1 | CalendarDateSelect.format = :iso_date
2 | CalendarDateSelect.default_options[:year_range] = 10.years.ago.to_date..Date.today
3 |
--------------------------------------------------------------------------------
/app/views/accounts/new.html.haml:
--------------------------------------------------------------------------------
1 | :javascript
2 | Accounts.origin = #{subscription_url(subscription).to_json};
3 |
4 | #new_account.form= render :partial => "accounts/form"
5 |
--------------------------------------------------------------------------------
/app/views/line_items/_line_item.html.haml:
--------------------------------------------------------------------------------
1 | = render :partial => "events/row", :locals => { :event => line_item.event, :amount => line_item.amount, :account_links => false }
2 |
--------------------------------------------------------------------------------
/app/helpers/tags_helper.rb:
--------------------------------------------------------------------------------
1 | module TagsHelper
2 | def possible_receiver_tags
3 | @possible_receiver_tags ||= (subscription.tags - [tag_ref]).sort_by(&:name)
4 | end
5 | end
6 |
--------------------------------------------------------------------------------
/app/views/tagged_items/_tagged_item.html.haml:
--------------------------------------------------------------------------------
1 | = render :partial => "events/row", :locals => { :event => tagged_item.event, :amount => tagged_item.amount, :account_links => true }
2 |
--------------------------------------------------------------------------------
/vendor/plugins/cached_externals/lib/cached_externals/git-hooks/post-merge:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 |
3 | if [ -f config/externals.yml ];
4 | then
5 | cap -q local externals:setup
6 | fi;
7 |
--------------------------------------------------------------------------------
/app/views/account_items/_account_item.html.haml:
--------------------------------------------------------------------------------
1 | = render :partial => "events/row", :locals => { :event => account_item.event, :amount => account_item.amount, :account_links => false }
2 |
--------------------------------------------------------------------------------
/config.ru:
--------------------------------------------------------------------------------
1 | require File.dirname(__FILE__) + '/config/environment'
2 |
3 | if Rails.env.development?
4 | use Rails::Rack::Static
5 | end
6 |
7 | run ActionController::Dispatcher.new
8 |
--------------------------------------------------------------------------------
/vendor/plugins/cached_externals/recipes/cached_externals.rb:
--------------------------------------------------------------------------------
1 | lib = File.expand_path('../../lib', __FILE__)
2 | $:.unshift(lib) unless $:.include?(lib)
3 | require 'cached_externals'
4 |
--------------------------------------------------------------------------------
/config/session.yml:
--------------------------------------------------------------------------------
1 | :key: _bucketwise_session
2 | :secret: "2f8e38ad3db3c9c3be9e60866b6d9137919c9ffb96431d6c0620217fe99fae08b1860b13a133dccd953192cc6b3dfb232031a00a6baa6cf7ce70edde39db5b09"
3 |
--------------------------------------------------------------------------------
/public/javascripts/application.js:
--------------------------------------------------------------------------------
1 | // Place your application-specific JavaScript functions and classes here
2 | // This file is automatically included by javascript_include_tag :defaults
3 |
--------------------------------------------------------------------------------
/vendor/plugins/cached_externals/lib/cached_externals/git-hooks/post-checkout:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 |
3 | if [[ "1" == $3 && -f config/externals.yml ]];
4 | then
5 | cap -q local externals:setup
6 | fi;
7 |
--------------------------------------------------------------------------------
/lib/bucket_wise/version.rb:
--------------------------------------------------------------------------------
1 | module BucketWise
2 | module Version
3 | MAJOR = 1
4 | MINOR = 1
5 | TINY = 0
6 |
7 | STRING = [MAJOR, MINOR, TINY].join(".")
8 | end
9 | end
10 |
--------------------------------------------------------------------------------
/app/helpers/statements_helper.rb:
--------------------------------------------------------------------------------
1 | module StatementsHelper
2 | def uncleared_row_class(item)
3 | classes = [cycle('odd', 'even')]
4 | classes << "cleared" if item.statement_id
5 | classes.join(" ")
6 | end
7 | end
8 |
--------------------------------------------------------------------------------
/app/views/events/update.js.rjs:
--------------------------------------------------------------------------------
1 | if !event.valid?
2 | errors = event.errors.full_messages.join("\n")
3 | page.alert("This transaction could not be saved:\n\n" + errors)
4 |
5 | else
6 | page.events.return_to_caller
7 | end
8 |
--------------------------------------------------------------------------------
/script/find:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env ruby
2 | Dir.chdir(File.join(File.dirname(__FILE__), "..")) do
3 | $LOAD_PATH.unshift "vendor/plugins/project_search/lib"
4 | require 'project_search'
5 |
6 | ProjectSearch.new(ARGV).search
7 | end
8 |
--------------------------------------------------------------------------------
/app/helpers/buckets_helper.rb:
--------------------------------------------------------------------------------
1 | module BucketsHelper
2 | def source_view
3 | params[:view] || "index"
4 | end
5 |
6 | def possible_receiver_buckets
7 | (account.buckets - [bucket]).sort_by(&:name)
8 | end
9 | end
10 |
--------------------------------------------------------------------------------
/public/robots.txt:
--------------------------------------------------------------------------------
1 | # See http://www.robotstxt.org/wc/norobots.html for documentation on how to use the robots.txt file
2 | #
3 | # To ban all spiders from the entire site uncomment the next two lines:
4 | # User-Agent: *
5 | # Disallow: /
6 |
--------------------------------------------------------------------------------
/app/views/subscriptions/index.html.haml:
--------------------------------------------------------------------------------
1 | #data.content
2 | %h2 Your Subscriptions
3 |
4 | %ul
5 | - user.subscriptions.each do |subscription|
6 | %li{:id => dom_id(subscription)}= link_to(subscription.id, subscription_path(subscription))
7 |
--------------------------------------------------------------------------------
/config/initializers/activerecord.rb:
--------------------------------------------------------------------------------
1 | module Pingalingaling
2 | def ping
3 | self.updated_at = Time.now.utc
4 | end
5 |
6 | def ping!
7 | ping
8 | save!
9 | end
10 | end
11 |
12 | ActiveRecord::Base.send(:include, Pingalingaling)
13 |
--------------------------------------------------------------------------------
/db/migrate/20090330132556_add_memo_field_to_event.rb:
--------------------------------------------------------------------------------
1 | class AddMemoFieldToEvent < ActiveRecord::Migration
2 | def self.up
3 | add_column :events, :memo, :text
4 | end
5 |
6 | def self.down
7 | remove_column :events, :memo
8 | end
9 | end
10 |
--------------------------------------------------------------------------------
/db/migrate/20090516072907_add_limit_to_accounts.rb:
--------------------------------------------------------------------------------
1 | class AddLimitToAccounts < ActiveRecord::Migration
2 | def self.up
3 | add_column :accounts, :limit, :integer
4 | end
5 |
6 | def self.down
7 | remove_column :accounts, :limit
8 | end
9 | end
10 |
--------------------------------------------------------------------------------
/app/views/events/edit.html.haml:
--------------------------------------------------------------------------------
1 | :javascript
2 | Events.return_to = #{(params[:return_to] || subscription_path(subscription)).to_json};
3 |
4 | #data.content
5 | %h2 Edit transaction
6 |
7 | .transaction.edit.form{:class => @event.role.to_s}= render :partial => "events/form"
8 |
--------------------------------------------------------------------------------
/app/views/tags/_name.html.haml:
--------------------------------------------------------------------------------
1 | %span.actions== #{link_to_function("Rename", "Tags.rename(#{tag_path(tag_ref).to_json}, #{tag_ref.name.to_json}, #{form_authenticity_token.to_json})")} | #{link_to_function("Delete", "Tags.deleteTag()")}
2 | == Transactions tagged "#{h(tag_ref.name)}"
3 |
--------------------------------------------------------------------------------
/config/deploy.rb:
--------------------------------------------------------------------------------
1 | # FIXME: common deployment stuff here, with a conditional load of the real
2 | # recipe file, so that the real stuff can exist locally without being
3 | # checked into the main repo.
4 |
5 | capfile = File.expand_path("~/.bucketwise/Capfile")
6 | load(capfile) if File.exists?(capfile)
7 |
--------------------------------------------------------------------------------
/app/views/statements/_subtotal.html.haml:
--------------------------------------------------------------------------------
1 | .subtotal
2 | .settled
3 | Subtotal:
4 | %span.subtotal_dollars= format_cents(subtotal)
5 | .remaining
6 | Remaining:
7 | %span.remaining_dollars{:class => statement.unsettled_balance.zero? ? "balanced" : nil}
8 | = format_cents(statement.unsettled_balance)
9 |
--------------------------------------------------------------------------------
/app/views/buckets/update.js.rjs:
--------------------------------------------------------------------------------
1 | if bucket.valid?
2 | page[dom_id(bucket, :name)].replace_html :partial => "buckets/bucket_name_for_#{source_view}",
3 | :locals => { :bucket => bucket }
4 | else
5 | errors = bucket.errors.full_messages.join("\n")
6 | page.alert("The bucket could not be updated:\n\n" + errors);
7 | end
8 |
--------------------------------------------------------------------------------
/app/views/buckets/_bucket_name_for_perma.html.haml:
--------------------------------------------------------------------------------
1 | %span.actions== #{link_to_function("Rename", "Buckets.rename(#{bucket_path(bucket).to_json}, #{bucket.name.to_json}, #{form_authenticity_token.to_json})")} | #{link_to_function("Delete/Merge", "Buckets.deleteBucket()")}
2 | == #{link_to(h(bucket.account.name), bucket.account)}:
3 | &= bucket.name
4 |
--------------------------------------------------------------------------------
/Rakefile:
--------------------------------------------------------------------------------
1 | # Add your own tasks in files placed in lib/tasks ending in .rake,
2 | # for example lib/tasks/capistrano.rake, and they will automatically be available to Rake.
3 |
4 | require(File.join(File.dirname(__FILE__), 'config', 'boot'))
5 |
6 | require 'rake'
7 | require 'rake/testtask'
8 | require 'rake/rdoctask'
9 |
10 | require 'tasks/rails'
11 |
--------------------------------------------------------------------------------
/app/views/buckets/_bucket.html.haml:
--------------------------------------------------------------------------------
1 | %tr.bucket{:id => dom_id(bucket), :class => cycle('odd', 'even'), :onmouseover => "Buckets.onMouseOver(#{bucket.id})", :onmouseout => "Buckets.onMouseOut(#{bucket.id})"}
2 |
3 | %td{:id => dom_id(bucket, :name)}
4 | = render :partial => "buckets/bucket_name_for_index", :locals => { :bucket => bucket }
5 |
6 | = balance_cell(bucket)
7 |
--------------------------------------------------------------------------------
/test/fixtures/user_subscriptions.yml:
--------------------------------------------------------------------------------
1 | john:
2 | subscription: john
3 | user: john
4 | created_at: <%= 60.days.ago.utc.to_s(:db) %>
5 |
6 | tim:
7 | subscription: tim
8 | user: tim
9 | created_at: <%= 59.days.ago.utc.to_s(:db) %>
10 |
11 | john_family:
12 | subscription: john_family
13 | user: john
14 | created_at: <%= 55.days.ago.utc.to_s(:db) %>
15 |
--------------------------------------------------------------------------------
/app/views/events/_reallocation_item.html.haml:
--------------------------------------------------------------------------------
1 | Reallocate
2 | = "$" + text_field_tag("amount", line_item_amount_value(reallocation_item), :class => "number", :size => 9)
3 | == #{reallocation_verbs_for(section).last}
4 | = select_bucket(section, :splittable => false, :line_item => reallocation_item)
5 | = link_to_function "[x]", "Events.removeLineItem(this.up('li'))"
6 |
--------------------------------------------------------------------------------
/app/views/events/new.html.haml:
--------------------------------------------------------------------------------
1 | :javascript
2 | Events.return_to = #{(params[:return_to] || subscription_path(subscription)).to_json};
3 | Events.defaultActor = "Bucket reallocation";
4 | Events.defaultDate = #{Date.today.to_json};
5 |
6 | #data.content
7 | %h2 New transaction
8 |
9 | .transaction.form{:class => @event.role.to_s}= render :partial => "events/form"
10 |
--------------------------------------------------------------------------------
/app/views/accounts/update.js.rjs:
--------------------------------------------------------------------------------
1 | if account.valid?
2 | page[dom_id(account, :name)].replace_html :partial => "accounts/name",
3 | :locals => { :account => account }
4 | page[dom_id(account, :buckets)].replace_html("Buckets in #{h(account.name)}")
5 | else
6 | errors = account.errors.full_messages.join("\n")
7 | page.alert("The account could not be updated:\n\n" + errors)
8 | end
9 |
--------------------------------------------------------------------------------
/app/views/events/_line_item.html.haml:
--------------------------------------------------------------------------------
1 | == $#{text_field_tag "event[#{section}][amount]", line_item_amount_value(line_item), :size => 8, :class => "number", :onchange => "Events.updateUnassignedFor('#{section}')"}
2 | = bucket_action_phrase_for(section)
3 | = select_bucket(section, :splittable => false, :line_item => line_item)
4 | = link_to_function "[x]", "Events.removeLineItem(this.up('li'))"
5 |
--------------------------------------------------------------------------------
/config/initializers/redirect_log.rb:
--------------------------------------------------------------------------------
1 | # For use from the console, so you can view the log output without
2 | # having to tail a log in a separate terminal window.
3 | def redirect_log(options={})
4 | ActiveRecord::Base.logger = Logger.new(options.fetch(:to, STDERR))
5 | ActiveRecord::Base.clear_active_connections!
6 | ActiveRecord::Base.colorize_logging = options.fetch(:colorize, true)
7 | end
8 |
--------------------------------------------------------------------------------
/app/views/tags/show.html.haml:
--------------------------------------------------------------------------------
1 | #delete_form.form{:style => "display: none;"}= render :partial => "tags/delete_form"
2 |
3 | #data.content
4 | .navigation
5 | = link_to "Dashboard", subscription_path(subscription)
6 |
7 | %h2#name= render :partial => "tags/name"
8 |
9 | %table.entries
10 | = render :partial => "events/balance", :locals => { :container => tag_ref }
11 | = render(@items)
12 |
--------------------------------------------------------------------------------
/app/concerns/categorized_items.rb:
--------------------------------------------------------------------------------
1 | module CategorizedItems
2 | def deposits
3 | @deposits ||= to_a.select { |item| item.amount > 0 }
4 | end
5 |
6 | def checks
7 | @checks ||= to_a.select { |item| item.amount < 0 && item.event.check_number.present? }
8 | end
9 |
10 | def expenses
11 | @expenses ||= to_a.select { |item| item.amount < 0 && item.event.check_number.blank? }
12 | end
13 | end
14 |
--------------------------------------------------------------------------------
/app/views/events/_balance.html.haml:
--------------------------------------------------------------------------------
1 | %tr.current_balance
2 | - if container.is_a?(Statement)
3 | %th.date= statement.occurred_on.strftime("%Y-%m-%d")
4 | %th Ending balance
5 | - else
6 | %th.date= Date.today.strftime("%Y-%m-%d")
7 | %th Current balance
8 | %th.number.total.balance{:colspan => 2}= balance_cell(container, :tag => "span", :id => "balance")
9 | %tr.spacer
10 | %td{:colspan => 4}
11 |
--------------------------------------------------------------------------------
/public/javascripts/filters.js:
--------------------------------------------------------------------------------
1 | var Filters = {
2 | display: function() {
3 | var form = $('filter_form');
4 | var nub = $('filter_nubbin');
5 |
6 | //form.style.right = (nub.offsetLeft + nub.getWidth()) + "px";
7 | form.show();
8 | form.style.left = (nub.offsetLeft + nub.getWidth() - form.getWidth()) + "px";
9 | },
10 |
11 | hide: function() {
12 | $('filter_form').hide();
13 | }
14 | }
15 |
--------------------------------------------------------------------------------
/vendor/plugins/cached_externals/lib/tasks/git.rake:
--------------------------------------------------------------------------------
1 | namespace :git do
2 | namespace :hooks do
3 | desc "Install some git hooks for updating cached externals"
4 | task :install do
5 | Dir["#{RAILS_ROOT}/vendor/plugins/cached_externals/script/git-hooks/*"].each do |hook|
6 | cp hook, ".git/hooks"
7 | chmod 0755, ".git/hooks/#{File.basename(hook)}"
8 | end
9 | end
10 | end
11 | end
12 |
--------------------------------------------------------------------------------
/app/helpers/accounts_helper.rb:
--------------------------------------------------------------------------------
1 | module AccountsHelper
2 | def account_starting_balance_amount
3 | if @account && @account.starting_balance
4 | amount = @account.starting_balance[:amount].to_i
5 | "%.2f" % (amount / 100.0) unless amount.zero?
6 | end
7 | end
8 |
9 | def account_starting_balance_occurred_on
10 | @account.starting_balance[:occurred_on].to_date rescue Date.today
11 | end
12 | end
13 |
--------------------------------------------------------------------------------
/app/views/accounts/show.html.haml:
--------------------------------------------------------------------------------
1 | #data.content
2 | .navigation
3 | = link_to "Dashboard", subscription_path(subscription)
4 | = link_to "Buckets in #{h(account.name)}", account_buckets_path(account), :id => dom_id(account, :buckets)
5 |
6 | %h2{:id => dom_id(account, :name)}= render :partial => "accounts/name"
7 |
8 | %table.entries
9 | = render :partial => "events/balance", :locals => { :container => account }
10 | = render(@items)
11 |
--------------------------------------------------------------------------------
/test/fixtures/tagged_items.yml:
--------------------------------------------------------------------------------
1 | john_lunch_tip:
2 | event: john_lunch
3 | tag: john_tip
4 | occurred_on: <%= 59.days.ago.to_date.to_s(:db) %>
5 | amount: 100
6 |
7 | john_lunch_lunch:
8 | event: john_lunch
9 | tag: john_lunch
10 | occurred_on: <%= 59.days.ago.to_date.to_s(:db) %>
11 | amount: 775
12 |
13 | john_bare_mastercard:
14 | event: john_bare_mastercard
15 | tag: john_fuel
16 | occurred_on: <%= 55.days.ago.to_date.to_s(:db) %>
17 | amount: 1500
18 |
--------------------------------------------------------------------------------
/app/views/accounts/_account.html.haml:
--------------------------------------------------------------------------------
1 | %tr.account{:id => dom_id(account)}
2 | %th= link_to(account.name, account)
3 | = balance_cell(account, :classes => "total")
4 | - if account.buckets.length > 1
5 | - reset_cycle
6 | = render(account.buckets.recent)
7 | - if account.buckets.length > Bucket::RECENT_WINDOW_SIZE
8 | %tr.bucket
9 | %td.more{:colspan => 2}
10 | →
11 | = link_to "See all #{account.buckets.length} buckets", account_buckets_path(account)
12 |
--------------------------------------------------------------------------------
/app/views/events/_tagged_item.html.haml:
--------------------------------------------------------------------------------
1 | == $#{text_field_tag "event[tags][amount]", tagged_item_amount_value(tagged_item), :size => 8, :class => "number"}
2 | is tagged as
3 | = tag_entry_field "event[tags][name]", tagged_item_name_value(tagged_item), :size => 12, :class => "tag", :id => tagged_item ? dom_id(tagged_item, :tag_name) : "{ID}"
4 | - if tagged_item
5 | :javascript
6 | Events.autocompleteTagField('#{dom_id(tagged_item, :tag_name)}');
7 | = link_to_function "[x]", "Events.removeTaggedItem(this.up('li'))"
8 |
--------------------------------------------------------------------------------
/app/views/buckets/_bucket_name_for_index.html.haml:
--------------------------------------------------------------------------------
1 | .nubbin{:id => dom_id(bucket, :nubbin), :style => "display: none"}
2 | = link_to_function("+", "Buckets.transferTo(#{bucket.account_id}, #{bucket.id})", :class => "button")
3 | = link_to_function("-", "Buckets.transferFrom(#{bucket.account_id}, #{bucket.id})", :class => "button")
4 | = link_to_function("✎", "Buckets.rename(#{bucket_path(bucket).to_json}, #{bucket.name.to_json}, #{form_authenticity_token.to_json})", :class => "button")
5 | = link_to(h(bucket.name), bucket, :class => "bucket")
6 |
--------------------------------------------------------------------------------
/vendor/plugins/cached_externals/cached_externals.gemspec:
--------------------------------------------------------------------------------
1 | Gem::Specification.new do |s|
2 | s.name = 'cached_externals'
3 | s.version = '1.0.0'
4 | s.date = '2010-03-29'
5 | s.summary = 'Symlink to external dependencies, rather than bloating your repositories with them'
6 | s.description = s.summary
7 |
8 | s.add_dependency('capistrano')
9 |
10 | s.files = Dir['lib/**/*']
11 |
12 | s.author = 'Jamis Buck'
13 | s.email = 'jamis@jamisbuck.org'
14 | s.homepage = 'http://github.com/37signals/cached_externals'
15 | end
16 |
--------------------------------------------------------------------------------
/app/concerns/pageable.rb:
--------------------------------------------------------------------------------
1 | module Pageable
2 | DEFAULT_PAGE_SIZE = 100
3 |
4 | def self.included(base)
5 | base.extend(ClassMethods)
6 | end
7 |
8 | module ClassMethods
9 | def page(n, options={})
10 | n = n.to_i
11 | size = (options[:size] || DEFAULT_PAGE_SIZE).to_i
12 |
13 | records = find(:all, :include => { :event => :line_items },
14 | :order => "occurred_on DESC",
15 | :limit => size + 1,
16 | :offset => n * size)
17 |
18 | [records.length > size, records[0,size]]
19 | end
20 | end
21 | end
22 |
--------------------------------------------------------------------------------
/app/models/actor.rb:
--------------------------------------------------------------------------------
1 | class Actor < ActiveRecord::Base
2 | belongs_to :subscription
3 | has_many :events
4 |
5 | validates_presence_of :name, :sort_name
6 | attr_accessible :name, :sort_name
7 |
8 | def self.normalize_name(name)
9 | name.strip.upcase
10 | end
11 |
12 | def self.normalize(name)
13 | name = name.strip
14 | sort_name = normalize_name(name)
15 |
16 | actor = find_by_sort_name(sort_name)
17 | if actor
18 | actor.ping!
19 | return actor
20 | end
21 |
22 | create(:sort_name => sort_name, :name => name)
23 | end
24 | end
25 |
--------------------------------------------------------------------------------
/app/views/subscriptions/_blank_slate.html.haml:
--------------------------------------------------------------------------------
1 | %h2 Welcome to BucketWise!
2 |
3 | %p To get started, you'll need to create at least one account |
4 | (a bank account, credit card account, etc). "Accounts" are where your money is |
5 | actually stored. |
6 |
7 | %p.create== [ #{link_to_function("Click here to create your first account", "Accounts.revealForm()")} ]
8 |
9 | %p Once you've created your first account, you can create more accounts, |
10 | record transactions, and basically get on with keeping your finances |
11 | under control! |
12 |
--------------------------------------------------------------------------------
/config/database.yml:
--------------------------------------------------------------------------------
1 | # SQLite version 3.x
2 | # gem install sqlite3-ruby (not necessary on OS X Leopard)
3 | development:
4 | adapter: sqlite3
5 | database: db/development.sqlite3
6 | timeout: 5000
7 |
8 | # Warning: The database defined as 'test' will be erased and
9 | # re-generated from your development database when you run 'rake'.
10 | # Do not set this db to the same as development or production.
11 | test:
12 | adapter: sqlite3
13 | database: db/test.sqlite3
14 | timeout: 5000
15 |
16 | production:
17 | adapter: sqlite3
18 | database: db/production.sqlite3
19 | timeout: 5000
20 |
--------------------------------------------------------------------------------
/app/views/buckets/_delete_form.html.haml:
--------------------------------------------------------------------------------
1 | - form_tag(bucket_path(bucket), :method => 'delete', :onsubmit => "return Buckets.confirmDelete()") do
2 | %fieldset
3 | %legend Delete/Merge
4 | %p== To delete this bucket, you'll need to move the transactions from "#{h(bucket.name)}" into another bucket.
5 | %p
6 | Please select that other bucket:
7 | = select_tag(:receiver_id, options_from_collection_for_select(possible_receiver_buckets, :id, :name, account.buckets.default.id))
8 | %p
9 | = submit_tag "Merge and delete this bucket"
10 | == or #{link_to_function("Cancel", "Buckets.cancelDelete()")}
11 |
--------------------------------------------------------------------------------
/test/fixtures/users.yml:
--------------------------------------------------------------------------------
1 | john:
2 | name: John Johnson
3 | email: jjohnson@domain.test
4 | user_name: jjohnson
5 | password_hash: "d8fa32a5d48cf8104af1cd40f5132f894d6eb765" # hash of 'testing'
6 | salt: abcd1234
7 | created_at: <%= 60.days.ago.utc.to_s(:db) %>
8 | updated_at: <%= 60.days.ago.utc.to_s(:db) %>
9 |
10 | tim:
11 | name: Tim Taylor
12 | email: ttaylor@domain.test
13 | user_name: ttaylor
14 | password_hash: "d8fa32a5d48cf8104af1cd40f5132f894d6eb765" # hash of 'testing'
15 | salt: abcd1234
16 | created_at: <%= 59.days.ago.utc.to_s(:db) %>
17 | updated_at: <%= 59.days.ago.utc.to_s(:db) %>
18 |
--------------------------------------------------------------------------------
/config/routes.rb:
--------------------------------------------------------------------------------
1 | ActionController::Routing::Routes.draw do |map|
2 | map.resource :session
3 |
4 | map.resources :subscriptions, :has_many => [:accounts, :events, :tags]
5 | map.resources :events, :has_many => :tagged_items, :member => { :update => :post }
6 | map.resources :buckets, :has_many => :events
7 | map.resources :accounts, :has_many => [:buckets, :events, :statements]
8 | map.resources :tags, :has_many => :events
9 | map.resources :tagged_items, :statements
10 |
11 | map.with_options :controller => "subscriptions", :action => "index" do |home|
12 | home.root
13 | home.connect ""
14 | end
15 | end
16 |
--------------------------------------------------------------------------------
/app/views/buckets/show.html.haml:
--------------------------------------------------------------------------------
1 | #delete_form.form{:style => "display: none;"}= render :partial => "buckets/delete_form"
2 |
3 | #data.content
4 | .navigation
5 | = link_to "Dashboard", subscription_path(subscription)
6 | = link_to "Transactions in #{h(account.name)}", account_path(account)
7 | = link_to "Buckets in #{h(account.name)}", account_buckets_path(account)
8 |
9 | %h2{:id => dom_id(bucket, :name)}
10 | = render :partial => "buckets/bucket_name_for_perma", :locals => { :bucket => bucket }
11 |
12 | %table.entries
13 | = render :partial => "events/balance", :locals => { :container => bucket }
14 | = render(@items)
15 |
--------------------------------------------------------------------------------
/app/views/statements/show.html.haml:
--------------------------------------------------------------------------------
1 | #data.content
2 | .navigation
3 | = link_to "Dashboard", subscription_path(subscription)
4 | = link_to h(account.name), account_path(account)
5 | = link_to "Prior statements", account_statements_path(account)
6 |
7 | %h2
8 | %span.actions
9 | = link_to("Delete", statement_path(statement), :confirm => "Are you sure you want to delete this statement?", :method => :delete)
10 | Statement for period ending
11 | = statement.occurred_on.strftime("%Y-%m-%d")
12 |
13 | %table.entries
14 | = render :partial => "events/balance", :locals => { :container => statement }
15 | = render(statement.account_items)
16 |
--------------------------------------------------------------------------------
/test/unit/tagged_item_test.rb:
--------------------------------------------------------------------------------
1 | require 'test_helper'
2 |
3 | class TaggedItemTest < ActiveSupport::TestCase
4 | test "create should update balance on tag record" do
5 | initial_amount = tags(:john_lunch).balance
6 | events(:john_bill_pay).tagged_items.create({:tag => tags(:john_lunch), :amount => 1500}, :occurred_on => Date.today)
7 | assert_equal initial_amount + 1500, tags(:john_lunch, :reload).balance
8 | end
9 |
10 | test "destroy should update balance on tag record" do
11 | assert_not_equal 0, tags(:john_lunch).balance
12 | tagged_items(:john_lunch_lunch).destroy
13 | assert_equal 0, tags(:john_lunch, :reload).balance
14 | end
15 | end
16 |
--------------------------------------------------------------------------------
/app/controllers/subscriptions_controller.rb:
--------------------------------------------------------------------------------
1 | class SubscriptionsController < ApplicationController
2 | before_filter :find_subscription, :except => :index
3 |
4 | def index
5 | respond_to do |format|
6 | format.html do
7 | if user.subscriptions.length == 1
8 | redirect_to(subscription_url(user.subscriptions.first))
9 | return
10 | end
11 | end
12 |
13 | format.xml { render :xml => user.subscriptions.to_xml(:root => "subscriptions") }
14 | end
15 | end
16 |
17 | def show
18 | respond_to do |format|
19 | format.html
20 | format.xml { render :xml => subscription }
21 | end
22 | end
23 | end
24 |
--------------------------------------------------------------------------------
/app/views/events/create.js.rjs:
--------------------------------------------------------------------------------
1 | if !event.valid?
2 | errors = event.errors.full_messages.join("\n")
3 | page.alert("This transaction could not be saved:\n\n" + errors)
4 |
5 | elsif params[:source] == "new"
6 | page.redirect_to(subscription_url(subscription))
7 |
8 | else
9 | page['recent_entries'].replace_html :partial => "events/event",
10 | :collection => subscription.events.recent.last
11 | page['accounts_summary'].replace_html :partial => "accounts/account",
12 | :collection => subscription.accounts
13 | page.events.reset
14 | page[:success_notice].show
15 | page << emit_account_data_assignments
16 | page.visual_effect :highlight, dom_id(event)
17 | end
18 |
--------------------------------------------------------------------------------
/app/views/statements/index.html.haml:
--------------------------------------------------------------------------------
1 | #data.content
2 | .navigation
3 | = link_to "Dashboard", subscription_path(subscription)
4 | = link_to h(account.name), account_path(account)
5 |
6 | %h2 Previous Statements
7 |
8 | - if statements.empty?
9 | %p== You have not yet balanced this account against any statements from your financial institution. You may start by clicking #{link_to("here", new_account_statement_path(account))}, to begin balancing this account.
10 |
11 | - else
12 | %ul
13 | - statements.each do |statement|
14 | %li
15 | = link_to(statement.occurred_on.strftime("%Y-%m-%d"), statement_path(statement))
16 | = ":"
17 | = format_cents(statement.ending_balance)
18 |
--------------------------------------------------------------------------------
/app/views/events/_expand.html.haml:
--------------------------------------------------------------------------------
1 | %tr.zoom{:id => dom_id(event, :zoomed)}
2 | %td.zoom{:colspan => 4}
3 | .detail
4 | %table
5 | - reset_cycle('expanded'); event.line_items.each do |item|
6 | %tr{:class => cycle('odd', 'even', :name => 'expanded')}
7 | %td= link_to(h(item.account.name), item.account)
8 | %td= link_to(h(item.bucket.name), item.bucket)
9 | %td.number= item.amount > 0 ? format_amount(item.amount) : ""
10 | %td.negative.number= item.amount < 0 ? format_amount(item.amount) : ""
11 |
12 | - if event.tags.any?
13 | %p.tags= "✓ " + tag_links_for(event)
14 |
15 | - if event.memo.present?
16 | %p.memo&= event.memo
17 |
18 |
--------------------------------------------------------------------------------
/config/initializers/new_rails_defaults.rb:
--------------------------------------------------------------------------------
1 | # These settins change the behavior of Rails 2 apps and will be defaults
2 | # for Rails 3. You can remove this initializer when Rails 3 is released.
3 |
4 | # Only save the attributes that have changed since the record was loaded.
5 | ActiveRecord::Base.partial_updates = true
6 |
7 | # Include ActiveRecord class name as root for JSON serialized output.
8 | ActiveRecord::Base.include_root_in_json = true
9 |
10 | # Use ISO 8601 format for JSON serialized times and dates
11 | ActiveSupport.use_standard_json_time_format = true
12 |
13 | # Don't escape HTML entities in JSON, leave that for the #json_escape helper
14 | # if you're including raw json in an HTML page.
15 | ActiveSupport.escape_html_entities_in_json = false
--------------------------------------------------------------------------------
/config/environment.rb:
--------------------------------------------------------------------------------
1 | # Be sure to restart your server when you modify this file
2 |
3 | # Uncomment below to force Rails into production mode when
4 | # you don't control web/app server and can't set it the proper way
5 | # ENV['RAILS_ENV'] ||= 'production'
6 |
7 | # Specifies gem version of Rails to use when vendor/rails is not present
8 | RAILS_GEM_VERSION = '2.3.2' unless defined? RAILS_GEM_VERSION
9 |
10 | # Bootstrap the Rails environment, frameworks, and default configuration
11 | require File.join(File.dirname(__FILE__), 'boot')
12 |
13 | Rails::Initializer.run do |config|
14 | config.autoload_paths += %W( #{RAILS_ROOT}/app/concerns )
15 | config.time_zone = 'UTC'
16 | config.action_controller.session = YAML.load_file("#{RAILS_ROOT}/config/session.yml")
17 | end
18 |
--------------------------------------------------------------------------------
/db/migrate/20090404154634_cache_balances_on_buckets_and_accounts.rb:
--------------------------------------------------------------------------------
1 | class CacheBalancesOnBucketsAndAccounts < ActiveRecord::Migration
2 | def self.up
3 | add_column :accounts, :balance, :integer, :null => false, :default => 0
4 | add_column :buckets, :balance, :integer, :null => false, :default => 0
5 |
6 | Account.find_each do |account|
7 | balance = account.account_items.sum(:amount)
8 | account.update_attribute :balance, balance
9 | end
10 |
11 | Bucket.find_each do |bucket|
12 | balance = bucket.line_items.sum(:amount)
13 | bucket.update_attribute :balance, balance
14 | end
15 | end
16 |
17 | def self.down
18 | remove_column :accounts, :balance, :integer
19 | remove_column :buckets, :balance, :integer
20 | end
21 | end
22 |
--------------------------------------------------------------------------------
/app/controllers/sessions_controller.rb:
--------------------------------------------------------------------------------
1 | class SessionsController < ApplicationController
2 | skip_before_filter :authenticate
3 |
4 | layout nil
5 |
6 | def new
7 | end
8 |
9 | def create
10 | @user = User.authenticate(params[:user_name], params[:password])
11 |
12 | if @user.nil?
13 | flash[:failed] = true
14 | redirect_to(new_session_url)
15 | else
16 | session[:user_id] = @user.id
17 |
18 | if @user.subscriptions.length > 1
19 | redirect_to(subscriptions_url)
20 | else
21 | redirect_to(subscription_url(@user.subscriptions.first))
22 | end
23 | end
24 | end
25 |
26 | def destroy
27 | flash[:logged_out] = true
28 | session[:user_id] = nil
29 | redirect_to(new_session_url)
30 | end
31 | end
32 |
--------------------------------------------------------------------------------
/config/externals.yml:
--------------------------------------------------------------------------------
1 | vendor/rails:
2 | :type: git
3 | :repository: git://github.com/rails/rails.git
4 | :revision: v2.3.11
5 | vendor/plugins/calendar_date_select:
6 | :type: git
7 | :repository: git://github.com/timcharper/calendar_date_select.git
8 | :revision: 88b7caf7acecf31186661c0efd6bc606cdcc666d
9 | vendor/plugins/haml:
10 | :type: git
11 | :repository: git://github.com/haml/haml.git
12 | :revision: 3.0.25
13 | vendor/plugins/project_search:
14 | :type: git
15 | :repository: git://github.com/37signals/project_search.git
16 | :revision: 5d243711fbbd69ac08ba86418316bd15cffa0642
17 | vendor/plugins/safe_mass_assignment:
18 | :type: git
19 | :repository: git://github.com/jamis/safe_mass_assignment.git
20 | :revision: 35d5c39c367cb94e5bfdcd80b063a4cf1bd3885d
21 |
--------------------------------------------------------------------------------
/db/migrate/20090421221109_add_statements.rb:
--------------------------------------------------------------------------------
1 | class AddStatements < ActiveRecord::Migration
2 | def self.up
3 | create_table :statements do |t|
4 | t.integer :account_id, :null => false
5 | t.date :occurred_on, :null => false
6 | t.integer :starting_balance
7 | t.integer :ending_balance
8 | t.datetime :balanced_at
9 | t.timestamps
10 | end
11 |
12 | add_index :statements, %w(account_id occurred_on)
13 |
14 | add_column :account_items, :statement_id, :integer
15 | add_index :account_items, %w(statement_id occurred_on)
16 | end
17 |
18 | def self.down
19 | drop_table :statements
20 |
21 | remove_index :account_items, %w(statement_id occurred_on)
22 | remove_column :account_items, :statement_id
23 | end
24 | end
25 |
--------------------------------------------------------------------------------
/test/test_helper.rb:
--------------------------------------------------------------------------------
1 | ENV["RAILS_ENV"] = "test"
2 | require File.expand_path(File.dirname(__FILE__) + "/../config/environment")
3 | require 'test_help'
4 |
5 | class ActiveSupport::TestCase
6 | fixtures :all
7 |
8 | protected
9 |
10 | def login_default_user
11 | login! :john
12 | end
13 |
14 | private
15 |
16 | def login!(who)
17 | @user = Symbol === who ? users(who) : who
18 | @request.session[:user_id] = @user.id
19 | end
20 |
21 | def logout!
22 | @request.session[:user_id] = nil
23 | end
24 |
25 | def api_login!(who, password)
26 | logout!
27 | @user = Symbol === who ? users(who) : who
28 | token = Base64.encode64("#{@user.user_name}:#{password}")
29 | @request.env['HTTP_AUTHORIZATION'] = "Basic: #{token}"
30 | end
31 | end
32 |
--------------------------------------------------------------------------------
/app/views/events/_form_reallocate.html.haml:
--------------------------------------------------------------------------------
1 | %fieldset{:id => section, :style => section_visible_for_event?(section) ? nil : "display: none"}
2 | %legend Reallocate funds
3 |
4 | %p.primary
5 | = hidden_field_tag "account_for_#{section}", account_id_for_section(section)
6 |
7 | == Which bucket are you reallocating funds #{reallocation_verbs_for(section).first}?
8 | = select_bucket(section, :splittable => false, :line_item => line_item_for_section('primary'))
9 |
10 | %ol{:id => "#{section}.line_items"}
11 | - for_each_line_item_in(section) do |item|
12 | %li= render :partial => "events/reallocation_item", :object => item, :locals => { :section => section }
13 |
14 | %p= link_to_function "More buckets, please!", "Events.addLineItemTo('#{section}', true)"
15 |
--------------------------------------------------------------------------------
/config/environments/development.rb:
--------------------------------------------------------------------------------
1 | # Settings specified here will take precedence over those in config/environment.rb
2 |
3 | # In the development environment your application's code is reloaded on
4 | # every request. This slows down response time but is perfect for development
5 | # since you don't have to restart the webserver when you make code changes.
6 | config.cache_classes = false
7 |
8 | # Log error messages when you accidentally call methods on nil.
9 | config.whiny_nils = true
10 |
11 | # Show full error reports and disable caching
12 | config.action_controller.consider_all_requests_local = true
13 | config.action_view.debug_rjs = true
14 | config.action_controller.perform_caching = false
15 |
16 | # Don't care if the mailer can't send
17 | config.action_mailer.raise_delivery_errors = false
--------------------------------------------------------------------------------
/app/views/statements/_uncleared.html.haml:
--------------------------------------------------------------------------------
1 | %tr{:id => dom_id(uncleared), :class => uncleared_row_class(uncleared)}
2 | %td.checkbox= check_box_tag "statement[cleared][]", uncleared.id, uncleared.statement_id, :id => dom_id(uncleared, :check), :onclick => "Statements.toggleCleared(#{uncleared.id})"
3 | %td.date{:onclick => "Statements.clickItem(#{uncleared.id})"}= uncleared.occurred_on.strftime("%Y-%m-%d")
4 | - if uncleared.event.check_number
5 | %td.check{:onclick => "Statements.clickItem(#{uncleared.id})"}= "#" + uncleared.event.check_number.to_s
6 | %td.actor{:onclick => "Statements.clickItem(#{uncleared.id})"}&= uncleared.event.actor_name
7 | %td.number{:onclick => "Statements.clickItem(#{uncleared.id})"}
8 | %span{:style => "display: none;", :id => dom_id(uncleared, :amount)}= uncleared.amount
9 | = format_cents(uncleared.amount.abs)
10 |
--------------------------------------------------------------------------------
/app/views/tags/_delete_form.html.haml:
--------------------------------------------------------------------------------
1 | - form_tag(tag_path(tag_ref), :method => 'delete', :onsubmit => "return Tags.confirmDelete()") do
2 | - if possible_receiver_tags.any?
3 | %fieldset
4 | %legend Delete/Merge
5 |
6 | %label#deleteTagOption.option.selected
7 | = radio_button_tag(:merge, 'no', true, :onclick => "Tags.selectDeleteTag()")
8 | == Delete the "#{h tag_ref.name}" tag
9 |
10 | %label#mergeTagOption.option
11 | = radio_button_tag(:merge, 'yes', false, :onclick => "Tags.selectMergeTag()")
12 | == Merge the "#{h tag_ref.name}" tag into
13 | = select_tag(:receiver_id, "" + options_from_collection_for_select(possible_receiver_tags, :id, :name))
14 |
15 | %p
16 | = submit_tag "Delete this tag"
17 | or
18 | = link_to_function("Cancel", "Tags.cancelDelete()")
19 |
--------------------------------------------------------------------------------
/app/views/sessions/new.html.haml:
--------------------------------------------------------------------------------
1 | !!!
2 | %html
3 | %head
4 | %meta{"http-equiv" => "Content-Type", :content => "text/html; charset=utf-8"}/
5 | %title Log into BucketWise
6 | = stylesheet_link_tag "money"
7 | = javascript_include_tag "prototype"
8 |
9 | %body.login
10 | .login_form
11 | - if flash[:failed]
12 | .alert The user name or password you gave was incorrect. Please try again.
13 | - if flash[:logged_out]
14 | .notice You have been logged out.
15 | - form_tag(:session, :url => session_path) do
16 | %p
17 | User name:
18 | = text_field_tag :user_name
19 | %p
20 | Password:
21 | = password_field_tag :password
22 | %p= submit_tag "Log into BucketWise"
23 |
24 | :javascript
25 | window.onload = function() { $('user_name').focus(); }
26 |
--------------------------------------------------------------------------------
/app/views/events/destroy.js.rjs:
--------------------------------------------------------------------------------
1 | page.events.destroy(event.id)
2 |
3 | case params[:from]
4 | when "subscriptions", "events" then
5 | page['accounts_summary'].replace_html(:partial => "accounts/account",
6 | :collection => subscription.accounts(:reload))
7 | when /accounts\/(\d+)/ then
8 | account = subscription.accounts.find($1)
9 | page['balance'].replace(balance_cell(account, :classes => %w(total balance), :tag => "span", :id => "balance"))
10 | when /buckets\/(\d+)/ then
11 | bucket = Bucket.find($1)
12 | subscription.accounts.find(bucket.account_id)
13 | page['balance'].replace(balance_cell(bucket, :classes => %w(total balance), :tag => "span", :id => "balance"))
14 | when /tags\/(\d+)/ then
15 | tag = subscription.tags.find($1)
16 | page['balance'].replace(balance_cell(tag, :classes => %w(total balance), :tag => "span", :id => "balance"))
17 | end
18 |
--------------------------------------------------------------------------------
/app/helpers/application_helper.rb:
--------------------------------------------------------------------------------
1 | # Methods added to this helper will be available to all templates in the application.
2 | module ApplicationHelper
3 | def visible?(flag)
4 | if flag
5 | nil
6 | else
7 | "display: none"
8 | end
9 | end
10 |
11 | def application_revision
12 | @application_revision ||= if File.exists?("#{RAILS_ROOT}/REVISION")
13 | File.read("#{RAILS_ROOT}/REVISION").strip
14 | else
15 | "HEAD"
16 | end
17 | end
18 |
19 | def application_last_deployed
20 | if File.exists?("#{RAILS_ROOT}/REVISION")
21 | @deployed_at ||= File.stat("#{RAILS_ROOT}/REVISION").ctime
22 | time_ago_in_words(@deployed_at) + " ago"
23 | else
24 | "(not deployed)"
25 | end
26 | end
27 |
28 | def format_cents(amount, options={})
29 | number_to_currency(amount/100.0, options)
30 | end
31 | end
32 |
--------------------------------------------------------------------------------
/app/views/accounts/_name.html.haml:
--------------------------------------------------------------------------------
1 | %span.actions
2 | - if account.credit_card?
3 | = link_to_function("Adjust Limit", "Accounts.adjustLimit(#{account_path(account).to_json}, #{account.limit.to_json}, #{form_authenticity_token.to_json})")
4 | |
5 | - if account.statements.pending.any?
6 | = link_to("Resume reconciling", edit_statement_path(account.statements.pending.first))
7 | - else
8 | = link_to("Reconcile", new_account_statement_path(account))
9 | |
10 | - if account.statements.balanced.any?
11 | = link_to("Prior statements", account_statements_path(account))
12 | |
13 | = link_to_function("Rename", "Accounts.rename(#{account_path(account).to_json}, #{account.name.to_json}, #{form_authenticity_token.to_json})")
14 | |
15 | = link_to("Delete", account_path(account), :method => :delete, :confirm => "Are you sure you want to delete this account?")
16 | &= account.name
17 |
--------------------------------------------------------------------------------
/lib/tasks/subscription.rake:
--------------------------------------------------------------------------------
1 | namespace :subscription do
2 | desc "List all users for a particular subscription (SUBSCRIPTION_ID env var)"
3 | task :users => :environment do
4 | subscription = Subscription.find(ENV['SUBSCRIPTION_ID'])
5 |
6 | if subscription.users.empty?
7 | puts "No users have access to subscription ##{subscription.id}"
8 | else
9 | puts "Users with access to ##{subscription.id}"
10 | subscription.users.each do |user|
11 | puts "##{user.id}: \"#{user.name}\" <#{user.email}>"
12 | end
13 | end
14 | end
15 |
16 | desc "Create a new subscription (USER_ID env var for owner)"
17 | task :create => :environment do
18 | owner = User.find(ENV['USER_ID'])
19 | subscription = Subscription.create(:owner => owner)
20 | owner.subscriptions << subscription
21 | puts "subscription ##{subscription.id} created for #{owner.user_name}"
22 | end
23 | end
24 |
--------------------------------------------------------------------------------
/app/models/tag.rb:
--------------------------------------------------------------------------------
1 | class Tag < ActiveRecord::Base
2 | belongs_to :subscription
3 |
4 | has_many :tagged_items, :dependent => :delete_all
5 |
6 | attr_accessible :name
7 |
8 | validates_presence_of :name
9 | validates_uniqueness_of :name, :scope => :subscription_id, :case_sensitive => false
10 |
11 | def self.template
12 | new :name => "name of tag"
13 | end
14 |
15 | def assimilate(tag)
16 | raise ActiveRecord::RecordNotSaved, "cannot assimilate self" if tag == self
17 |
18 | transaction do
19 | connection.update <<-SQL.squish
20 | UPDATE tagged_items
21 | SET tag_id = #{id}
22 | WHERE tag_id = #{tag.id}
23 | SQL
24 | tag.tagged_items.reset
25 |
26 | update_attribute :balance, balance + tag.balance
27 | tag.destroy
28 | end
29 | end
30 |
31 | def to_xml(options={})
32 | options[:only] = Array(options[:only]) + [:name] if new_record?
33 | super(options)
34 | end
35 | end
36 |
--------------------------------------------------------------------------------
/app/views/layouts/application.html.haml:
--------------------------------------------------------------------------------
1 | !!!
2 | %html{:lang => "en"}
3 | %head
4 | %meta{"http-equiv" => "Content-Type", :content => "text/html; charset=utf-8"}/
5 | %title BucketWise
6 | = stylesheet_link_tag "money"
7 | = javascript_include_tag :all
8 | = calendar_date_select_includes "default"
9 |
10 | %body
11 | :javascript
12 | Events.source = #{current_location.to_json};
13 |
14 | #header
15 | %h1
16 | %span.actions
17 | = link_to "log out", session_path, :method => :delete
18 | %span.title
19 | = link_to "BucketWise", root_path
20 |
21 | #container
22 | = yield
23 | #version
24 | == BucketWise version: v#{BucketWise::Version::STRING} (rev #{application_revision})
25 | %br
26 | == Last deployed: #{application_last_deployed}
27 |
--------------------------------------------------------------------------------
/app/views/statements/new.html.haml:
--------------------------------------------------------------------------------
1 | #data.content
2 | %h2== Let's reconcile your #{h(account.name)} account
3 |
4 | .form
5 |
6 | - form_for([account, statement]) do |form|
7 |
8 | %fieldset
9 |
10 | %p First, take a look at the account statement from your bank or other financial institution.
11 |
12 | %p If you haven't reconciled in a while, make sure you start with the oldest statement and work forward.
13 |
14 | %p
15 | When was the statement printed?
16 | = form.calendar_date_select :occurred_on, :size => 10
17 |
18 | %p
19 | What is the ending balance?
20 | = "$" + form.text_field(:ending_balance, :size => 8, :class => "number", :value => format_cents(statement.ending_balance, :unit => ""), :onchange => "this.value = Money.format(this)")
21 |
22 | %p
23 | = form.submit "Go to step #2"
24 | or
25 | = link_to("cancel", account_path(account))
26 |
--------------------------------------------------------------------------------
/config/environments/production.rb:
--------------------------------------------------------------------------------
1 | # Settings specified here will take precedence over those in config/environment.rb
2 |
3 | # The production environment is meant for finished, "live" apps.
4 | # Code is not reloaded between requests
5 | config.cache_classes = true
6 |
7 | # Use a different logger for distributed setups
8 | # config.logger = SyslogLogger.new
9 |
10 | # Full error reports are disabled and caching is turned on
11 | config.action_controller.consider_all_requests_local = false
12 | config.action_controller.perform_caching = true
13 | config.action_view.cache_template_loading = true
14 |
15 | # Use a different cache store in production
16 | # config.cache_store = :mem_cache_store
17 |
18 | # Enable serving of images, stylesheets, and javascripts from an asset server
19 | # config.action_controller.asset_host = "http://assets.example.com"
20 |
21 | # Disable delivery errors, bad email addresses will be ignored
22 | # config.action_mailer.raise_delivery_errors = false
23 |
--------------------------------------------------------------------------------
/public/422.html:
--------------------------------------------------------------------------------
1 |
3 |
4 |
5 |
6 |
7 |
8 | The change you wanted was rejected (422)
9 |
21 |
22 |
23 |
24 |
25 |
26 |
The change you wanted was rejected.
27 |
Maybe you tried to change something you didn't have access to.
28 |
29 |
30 |
--------------------------------------------------------------------------------
/test/fixtures/tags.yml:
--------------------------------------------------------------------------------
1 | # --------------------------------------------------------------
2 | # john
3 | # --------------------------------------------------------------
4 |
5 | john_tip:
6 | subscription: john
7 | name: tip
8 | balance: 100
9 | created_at: <%= 59.days.ago.utc.to_s(:db) %>
10 | updated_at: <%= 59.days.ago.utc.to_s(:db) %>
11 |
12 | john_lunch:
13 | subscription: john
14 | name: lunch
15 | balance: 775
16 | created_at: <%= 59.days.ago.utc.to_s(:db) %>
17 | updated_at: <%= 59.days.ago.utc.to_s(:db) %>
18 |
19 | john_fuel:
20 | subscription: john
21 | name: fuel
22 | balance: 1500
23 | created_at: <%= 55.days.ago.utc.to_s(:db) %>
24 | updated_at: <%= 55.days.ago.utc.to_s(:db) %>
25 |
26 | # --------------------------------------------------------------
27 | # tim
28 | # --------------------------------------------------------------
29 |
30 | tim_milk:
31 | subscription: tim
32 | name: milk
33 | balance: 0
34 | created_at: <%= 58.days.ago.utc.to_s(:db) %>
35 | updated_at: <%= 58.days.ago.utc.to_s(:db) %>
36 |
--------------------------------------------------------------------------------
/public/404.html:
--------------------------------------------------------------------------------
1 |
3 |
4 |
5 |
6 |
7 |
8 | The page you were looking for doesn't exist (404)
9 |
21 |
22 |
23 |
24 |
25 |
26 |
The page you were looking for doesn't exist.
27 |
You may have mistyped the address or the page may have moved.
28 |
29 |
30 |
--------------------------------------------------------------------------------
/public/500.html:
--------------------------------------------------------------------------------
1 |
3 |
4 |
5 |
6 |
7 |
8 | We're sorry, but something went wrong (500)
9 |
21 |
22 |
23 |
24 |
25 |
26 |
We're sorry, but something went wrong.
27 |
We've been notified about this issue and we'll take a look at it shortly.
28 |
29 |
30 |
--------------------------------------------------------------------------------
/config/environments/test.rb:
--------------------------------------------------------------------------------
1 | # Settings specified here will take precedence over those in config/environment.rb
2 |
3 | # The test environment is used exclusively to run your application's
4 | # test suite. You never need to work with it otherwise. Remember that
5 | # your test database is "scratch space" for the test suite and is wiped
6 | # and recreated between test runs. Don't rely on the data there!
7 | config.cache_classes = true
8 |
9 | # Log error messages when you accidentally call methods on nil.
10 | config.whiny_nils = true
11 |
12 | # Show full error reports and disable caching
13 | config.action_controller.consider_all_requests_local = true
14 | config.action_controller.perform_caching = false
15 |
16 | # Disable request forgery protection in test environment
17 | config.action_controller.allow_forgery_protection = false
18 |
19 | # Tell ActionMailer not to deliver emails to the real world.
20 | # The :test delivery method accumulates sent emails in the
21 | # ActionMailer::Base.deliveries array.
22 | config.action_mailer.delivery_method = :test
23 |
--------------------------------------------------------------------------------
/test/unit/account_item_test.rb:
--------------------------------------------------------------------------------
1 | require 'test_helper'
2 |
3 | class AccountItemTest < ActiveSupport::TestCase
4 | test "create should update balance on account record" do
5 | initial_amount = accounts(:john_checking).balance
6 |
7 | subscriptions(:john).events.create({:occurred_on => 3.days.ago.to_date,
8 | :actor_name => "Something",
9 | :line_items => [
10 | { :account_id => accounts(:john_checking).id,
11 | :bucket_id => buckets(:john_checking_groceries).id,
12 | :amount => -25_75,
13 | :role => 'payment_source' },
14 | ]
15 | }, :user => users(:john))
16 |
17 | assert_equal initial_amount - 25_75, accounts(:john_checking, :reload).balance
18 | end
19 |
20 | test "destroy should update balance on account record" do
21 | amount = account_items(:john_lunch_checking).amount
22 | current = accounts(:john_checking).balance
23 | account_items(:john_lunch_checking).destroy
24 | assert_equal current - amount, accounts(:john_checking, :reload).balance
25 | end
26 | end
27 |
--------------------------------------------------------------------------------
/app/concerns/option_handler.rb:
--------------------------------------------------------------------------------
1 | module OptionHandler
2 | private
3 |
4 | # For appending info to an XML serialization options hash, where the attributes
5 | # may be arrays, hashes, or singleton values.
6 | def append_to_options(options, attribute, extras)
7 | case options[attribute]
8 | when Array then
9 | case extras
10 | when Array then
11 | options[attribute].concat(extras)
12 | when Hash then
13 | old, options[attribute] = options[attribute], extras
14 | old.each { |key| options[attribute][key] ||= {} }
15 | else
16 | options[attribute] << extras
17 | end
18 |
19 | when Hash then
20 | case extras
21 | when Array then
22 | extras.each { |key| options[attribute][key] ||= {} }
23 | when Hash then
24 | extras.each { |key, value| options[attribute][key] ||= value }
25 | else
26 | options[attribute][extras] ||= {}
27 | end
28 |
29 | else
30 | options[attribute] = extras
31 | end
32 | end
33 | end
34 |
--------------------------------------------------------------------------------
/test/unit/line_item_test.rb:
--------------------------------------------------------------------------------
1 | require 'test_helper'
2 |
3 | class LineItemTest < ActiveSupport::TestCase
4 | test "create should update balance on bucket record" do
5 | initial_amount = buckets(:john_checking_groceries).balance
6 |
7 | subscriptions(:john).events.create({:occurred_on => 3.days.ago.to_date,
8 | :actor_name => "Something",
9 | :line_items => [
10 | { :account_id => accounts(:john_checking).id,
11 | :bucket_id => buckets(:john_checking_groceries).id,
12 | :amount => -25_75,
13 | :role => 'payment_source' },
14 | ]},
15 | :user => users(:john))
16 |
17 | assert_equal initial_amount - 25_75, buckets(:john_checking_groceries, :reload).balance
18 | end
19 |
20 | test "destroy should update balance on bucket record" do
21 | amount = line_items(:john_lunch_checking_dining).amount
22 | current = buckets(:john_checking_dining).balance
23 | line_items(:john_lunch_checking_dining).destroy
24 | assert_equal current - amount, buckets(:john_checking_dining, :reload).balance
25 | end
26 | end
27 |
--------------------------------------------------------------------------------
/test/fixtures/statements.yml:
--------------------------------------------------------------------------------
1 | # --------------------------------------------------------------
2 | # john
3 | # --------------------------------------------------------------
4 |
5 | john:
6 | account: john_checking
7 | occurred_on: <%= 2.weeks.ago.to_date.to_s(:db) %>
8 | starting_balance: 0
9 | ending_balance: 99225
10 | balanced_at: <%= 1.week.ago.to_s(:db) %>
11 | created_at: <%= 1.week.ago.to_s(:db) %>
12 | updated_at: <%= 1.week.ago.to_s(:db) %>
13 |
14 | john_pending:
15 | account: john_checking
16 | occurred_on: <%= Date.today %>
17 | starting_balance: 99225
18 | ending_balance: 125392
19 | balanced_at: ~
20 | created_at: <%= Time.now.to_s(:db) %>
21 | updated_at: <%= Time.now.to_s(:db) %>
22 |
23 | # --------------------------------------------------------------
24 | # tim
25 | # --------------------------------------------------------------
26 |
27 | tim:
28 | account: tim_checking
29 | occurred_on: <%= 1.week.ago.to_date.to_s(:db) %>
30 | starting_balance: 0
31 | ending_balance: 123456
32 | balanced_at: ~
33 | created_at: <%= 1.week.ago.to_s(:db) %>
34 | updated_at: <%= 1.week.ago.to_s(:db) %>
35 |
--------------------------------------------------------------------------------
/app/views/subscriptions/show.html.haml:
--------------------------------------------------------------------------------
1 | #links.header{:style => visible?(!blank_slate?)}
2 | %h2== Record a new |
3 | #{link_to_function "expense", "Events.revealExpenseForm()", :id => "expense_link"}, |
4 | #{link_to_function "deposit", "Events.revealDepositForm()", :id => "deposit_link"}, or |
5 | #{link_to_function "transfer", "Events.revealTransferForm()", :id => "transfer_link"} |
6 |
7 | #new_event.transaction.form{:style => "display: none"}= render :partial => "events/form"
8 |
9 | - if blank_slate?
10 | #blankslate= render :partial => "blank_slate"
11 |
12 | #new_account.form{:style => "display: none"}= render :partial => "accounts/form"
13 |
14 | #data.content{:style => visible?(!blank_slate?)}
15 | %h2 Recent transactions
16 |
17 | %table#recent_entries.entries= render(subscription.events.recent.last)
18 |
19 | %h2#accounts_summary_header
20 | %span.actions= link_to_function "Add an account", "Accounts.revealForm()"
21 | Accounts summary
22 |
23 | %table#accounts_summary= render(subscription.accounts)
24 |
25 | - if subscription.tags.any?
26 | %h2 Tags
27 | = render :partial => "tags/cloud"
28 |
--------------------------------------------------------------------------------
/db/migrate/20090506161959_normalize_actors.rb:
--------------------------------------------------------------------------------
1 | class NormalizeActors < ActiveRecord::Migration
2 | def self.up
3 | rename_column :events, :actor, :actor_name
4 | add_column :events, :actor_id, :integer
5 | add_index :events, :actor_id
6 |
7 | create_table :actors do |t|
8 | t.integer :subscription_id, :null => false
9 | t.string :name, :null => false
10 | t.string :sort_name, :null => false
11 | t.timestamps
12 | end
13 |
14 | add_index :actors, %w(subscription_id sort_name), :unique => true
15 | add_index :actors, %w(subscription_id updated_at)
16 |
17 | say_with_time "normalizing all existing event actors" do
18 | Event.find_each(:include => :subscription) do |event|
19 | say "normalizing event #{event.id}: #{event.actor_name.inspect}"
20 | event.update_attribute :actor, event.subscription.actors.normalize(event.actor_name)
21 | end
22 | end
23 | end
24 |
25 | def self.down
26 | drop_table :actors
27 |
28 | remove_index :events, :actor_id
29 | remove_column :events, :actor_id
30 | rename_column :events, :actor_name, :actor
31 | end
32 | end
33 |
--------------------------------------------------------------------------------
/vendor/plugins/cached_externals/LICENSE:
--------------------------------------------------------------------------------
1 | Copyright (c) 2008 by 37signals, LLC
2 |
3 | Permission is hereby granted, free of charge, to any person obtaining
4 | a copy of this software and associated documentation files (the
5 | "Software"), to deal in the Software without restriction, including
6 | without limitation the rights to use, copy, modify, merge, publish,
7 | distribute, sublicense, and/or sell copies of the Software, and to
8 | permit persons to whom the Software is furnished to do so, subject to
9 | the following conditions:
10 |
11 | The above copyright notice and this permission notice shall be
12 | included in all copies or substantial portions of the Software.
13 |
14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
17 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
19 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
21 |
--------------------------------------------------------------------------------
/app/models/user.rb:
--------------------------------------------------------------------------------
1 | require 'digest/sha1'
2 |
3 | class User < ActiveRecord::Base
4 | include OptionHandler
5 |
6 | has_many :user_subscriptions
7 | has_many :subscriptions, :through => :user_subscriptions
8 |
9 | attr_accessible :name, :email, :user_name, :password
10 |
11 | attr_writer :password
12 |
13 | before_save :set_password_hash_and_salt
14 |
15 | validates_uniqueness_of :user_name
16 |
17 | def self.password_hash_for(password, salt)
18 | Digest::SHA1.hexdigest(salt + password)
19 | end
20 |
21 | def self.authenticate(user_name, password)
22 | user = find_by_user_name(user_name) or return nil
23 | hash = password_hash_for(password, user.salt)
24 | hash == user.password_hash ? user : nil
25 | end
26 |
27 | def to_xml(options={})
28 | append_to_options(options, :except, [:password_hash, :salt])
29 | super(options)
30 | end
31 |
32 | protected
33 |
34 | def set_password_hash_and_salt
35 | if @password
36 | self.salt = Array.new(32) { 32 + rand(95) }.pack("C*")
37 | self.password_hash = self.class.password_hash_for(@password, salt)
38 | @password = nil
39 | end
40 | end
41 | end
42 |
--------------------------------------------------------------------------------
/app/views/events/_row.html.haml:
--------------------------------------------------------------------------------
1 | %tr{:id => dom_id(event), :class => cycle('odd', 'even'), :onmouseover => "Events.onMouseOver(#{event.id})", :onmouseout => "Events.onMouseOut(#{event.id})"}
2 |
3 | %td.date= event.occurred_on.strftime("%Y-%m-%d")
4 |
5 | %td
6 | .nubbin{:id => dom_id(event, :nubbin), :style => "display: none"}
7 | = link_to_function("✎", "Events.edit(#{edit_event_path(event).to_json})")
8 | = link_to_function("✖", "Events.deleteEvent(#{event_path(event).to_json}, #{form_authenticity_token.to_json})")
9 | = link_to_remote("ⓘ", :url => event_path(event), :method => :get, :before => "Events.expand(#{event.id})", :html => { :class => "zoom" })
10 | %span.zooming ⌛
11 | = link_to_function("✓", "Events.collapse(#{event.id})", :class => "zoomed")
12 | &= event.actor_name
13 | - if event.check_number.present?
14 | %span.check== ##{event.check_number}
15 | - if account_links
16 | %br/
17 | %span.account_links= links_to_accounts_for_event(event)
18 |
19 | %td.number= amount > 0 ? format_amount(amount) : ""
20 |
21 | %td.negative.number= amount < 0 ? format_amount(amount) : ""
22 |
--------------------------------------------------------------------------------
/app/controllers/tagged_items_controller.rb:
--------------------------------------------------------------------------------
1 | class TaggedItemsController < ApplicationController
2 | before_filter :find_event, :only => :create
3 | before_filter :find_tagged_item, :only => :destroy
4 |
5 | def create
6 | @tagged_item = event.tagged_items.create!(params[:tagged_item])
7 | respond_to do |format|
8 | format.xml do
9 | render :status => :created, :location => tagged_item_url(@tagged_item),
10 | :xml => @tagged_item
11 | end
12 | end
13 | rescue ActiveRecord::RecordInvalid => error
14 | respond_to do |format|
15 | format.xml { render :status => :unprocessable_entity, :xml => error.record.errors }
16 | end
17 | end
18 |
19 | def destroy
20 | tagged_item.destroy
21 | respond_to { |format| format.xml { head :ok } }
22 | end
23 |
24 | protected
25 |
26 | attr_reader :event, :tagged_item
27 | helper_method :event, :tagged_item
28 |
29 | def find_event
30 | @event = Event.find(params[:event_id])
31 | @subscription = user.subscriptions.find(@event.subscription_id)
32 | end
33 |
34 | def find_tagged_item
35 | @tagged_item = TaggedItem.find(params[:id])
36 | @event = @tagged_item.event
37 | @subscription = user.subscriptions.find(@event.subscription_id)
38 | end
39 | end
40 |
--------------------------------------------------------------------------------
/test/fixtures/accounts.yml:
--------------------------------------------------------------------------------
1 | # --------------------------------------------------------------
2 | # john
3 | # --------------------------------------------------------------
4 |
5 | john_checking:
6 | subscription: john
7 | author: john
8 | name: Checking
9 | role: checking
10 | balance: 99225
11 | created_at: <%= (60.days.ago + 1.hour).utc.to_s(:db) %>
12 | updated_at: <%= (60.days.ago + 1.hour).utc.to_s(:db) %>
13 |
14 | john_mastercard:
15 | subscription: john
16 | author: john
17 | name: Mastercard
18 | role: credit-card
19 | balance: -2525
20 | limit: 5000
21 | created_at: <%= (60.days.ago + 2.hours).utc.to_s(:db) %>
22 | updated_at: <%= (60.days.ago + 2.hours).utc.to_s(:db) %>
23 |
24 | john_savings:
25 | subscription: john
26 | author: john
27 | name: Savings
28 | role: ~
29 | balance: 0
30 | created_at: <%= (60.days.ago + 3.hours).utc.to_s(:db) %>
31 | updated_at: <%= (60.days.ago + 3.hours).utc.to_s(:db) %>
32 |
33 | # --------------------------------------------------------------
34 | # tim
35 | # --------------------------------------------------------------
36 |
37 | tim_checking:
38 | subscription: tim
39 | author: tim
40 | name: Checking
41 | role: checking
42 | balance: 125000
43 | created_at: <%= (59.days.ago + 1.hour).utc.to_s(:db) %>
44 | updated_at: <%= (59.days.ago + 1.hour).utc.to_s(:db) %>
45 |
--------------------------------------------------------------------------------
/app/views/events/_form_tags.html.haml:
--------------------------------------------------------------------------------
1 | %p#tags_collapsed{:style => event_has_tags? ? "display: none" : nil}
2 | →
3 | = link_to_function "I'd like to add tags for this transaction...", "Events.revealTags()"
4 |
5 | %fieldset#tags{:style => event_has_tags? ? nil : "display: none"}
6 | %legend Tags
7 |
8 | %p
9 | Tags for this transaction:
10 | %br/
11 | = text_field_tag "event[tags][list]", tag_list_for_event, :style => "width: 95%"
12 | %br/
13 | %small Enter tag names, separated by commas, that will apply to this entire transaction.
14 |
15 | #event_tags_list_select.autocomplete_select{:style => "display: none"}
16 | :javascript
17 | Events.autocompleteTagField('event_tags_list', {tokens: ','});
18 |
19 | %p#tag_items_collapsed{:style => event_has_partial_tags? ? "display: none" : nil}
20 | →
21 | = link_to_function "I'd like to add tags for parts of this transaction...", "Events.revealPartialTags()"
22 |
23 | #tag_items{:style => event_has_partial_tags? ? nil : "display: none"}
24 | %p Specify an amount for each tag. That amount will then be marked by that tag.
25 |
26 | %ol#tagged_items
27 | - for_each_partial_tagged_item do |item|
28 | %li= render :partial => "events/tagged_item", :locals => { :tagged_item => item }
29 |
30 | %p= link_to_function "More tags, please!", "Events.addTaggedItem()"
31 |
--------------------------------------------------------------------------------
/app/views/events/_form.html.haml:
--------------------------------------------------------------------------------
1 | = javascript_tag(emit_account_data_assignments)
2 |
3 | #templates{:style => "display: none"}
4 | - form_sections.each do |section|
5 | %div{:id => "template.#{section}"}
6 | = render :partial => template_partial_for(section), :locals => { :section => section }
7 |
8 | - form_for(:event, event_for_form, :url => event_form_action, :html => { :id => "event_form", :onsubmit => "Events.submit(this); return false" }) do |form|
9 | #success_notice.notice{:style => "display: none"}
10 | %p== Your transaction has been recorded. You may enter another one, |
11 | or #{link_to_function "close this form", "Events.cancel()"}. |
12 |
13 | - if event_wants_section?('general_information')
14 | = render :partial => "events/form_general", :locals => { :form => form }
15 | - else
16 | :javascript
17 | Events.defaultDate = #{@event.occurred_on.strftime("%Y-%m-%d").to_json};
18 | Events.defaultActor = #{@event.actor_name.to_json};
19 |
20 | - form_sections.each do |section|
21 | = render_event_form_section(form, section) if event_wants_section?(section)
22 |
23 | %p
24 | - if !event_for_form.new_record?
25 | %input{:type => "submit", :value => "Edit this transaction"}/
26 | - else
27 | %input{:type => "submit", :value => "Record this transaction"}/
28 | or
29 | = link_to_function "cancel", "Events.cancel()"
30 |
--------------------------------------------------------------------------------
/app/controllers/statements_controller.rb:
--------------------------------------------------------------------------------
1 | class StatementsController < ApplicationController
2 | before_filter :find_account, :only => %w(index new create)
3 | before_filter :find_statement, :only => %w(show edit update destroy)
4 |
5 | def index
6 | @statements = account.statements.balanced
7 | end
8 |
9 | def new
10 | @statement = account.statements.build(:ending_balance => account.balance,
11 | :occurred_on => Date.today)
12 | end
13 |
14 | def create
15 | @statement = account.statements.create(params[:statement])
16 | redirect_to(edit_statement_url(@statement))
17 | end
18 |
19 | def show
20 | end
21 |
22 | def edit
23 | @uncleared = account.account_items.uncleared(:with => statement, :include => :event)
24 | end
25 |
26 | def update
27 | statement.update_attributes(params[:statement])
28 | redirect_to(account)
29 | end
30 |
31 | def destroy
32 | statement.destroy
33 | redirect_to(account)
34 | end
35 |
36 | protected
37 |
38 | attr_reader :uncleared, :statements
39 | helper_method :uncleared, :statements
40 |
41 | attr_reader :account, :statement
42 | helper_method :account, :statement
43 |
44 | def find_account
45 | @account = Account.find(params[:account_id])
46 | @subscription = user.subscriptions.find(@account.subscription_id)
47 | end
48 |
49 | def find_statement
50 | @statement = Statement.find(params[:id])
51 | @account = @statement.account
52 | @subscription = user.subscriptions.find(@account.subscription_id)
53 | end
54 | end
55 |
--------------------------------------------------------------------------------
/test/functional/sessions_controller_test.rb:
--------------------------------------------------------------------------------
1 | require 'test_helper'
2 |
3 | class SessionsControllerTest < ActionController::TestCase
4 | test "new should render login page" do
5 | get :new
6 | assert_response :success
7 | assert_template "sessions/new"
8 | end
9 |
10 | test "create should redirect to login page on bad user name" do
11 | post :create, :user_name => "jimjim", :password => "whatever"
12 | assert_redirected_to new_session_url
13 | assert @response.session[:user_id].blank?
14 | end
15 |
16 | test "create should redirect to login page on bad password" do
17 | post :create, :user_name => "john", :password => "whatever"
18 | assert_redirected_to new_session_url
19 | assert @response.session[:user_id].blank?
20 | end
21 |
22 | test "create should redirect to subscription page on success when only one subscription" do
23 | post :create, :user_name => "ttaylor", :password => "testing"
24 | assert_redirected_to subscription_url(subscriptions(:tim))
25 | assert_equal users(:tim).id, @response.session[:user_id]
26 | end
27 |
28 | test "create should redirect to subscription index on success when multiple subscriptions" do
29 | post :create, :user_name => "jjohnson", :password => "testing"
30 | assert_redirected_to subscriptions_url
31 | assert_equal users(:john).id, @response.session[:user_id]
32 | end
33 |
34 | test "destroy should remove user_id from session" do
35 | login! :john
36 |
37 | get :destroy
38 | assert_redirected_to new_session_url
39 | assert @response.session[:user_id].blank?
40 | end
41 | end
42 |
--------------------------------------------------------------------------------
/app/models/tagged_item.rb:
--------------------------------------------------------------------------------
1 | class TaggedItem < ActiveRecord::Base
2 | include OptionHandler, Pageable
3 |
4 | belongs_to :event
5 | belongs_to :tag
6 |
7 | before_create :ensure_consistent_tag, :increment_tag_balance, :ensure_occurred_on
8 | before_destroy :decrement_tag_balance
9 |
10 | attr_accessible :tag, :tag_id, :amount
11 |
12 | delegate :name, :to => :tag
13 |
14 | def tag_id=(value)
15 | case value
16 | when Fixnum, /^\s*\d+\s*$/ then super(value)
17 | else @tag_to_translate = value
18 | end
19 | end
20 |
21 | def to_xml(options={})
22 | options[:except] = Array(options[:except])
23 | options[:except].concat [:event_id, :occurred_on]
24 | options[:except] << :tag_id unless new_record?
25 |
26 | append_to_options(options, :include, :tag => { :except => :subscription_id })
27 | super(options)
28 | end
29 |
30 | protected
31 |
32 | def ensure_consistent_tag
33 | if @tag_to_translate =~ /^n:(.*)/
34 | self.tag_id = event.subscription.tags.find_or_create_by_name($1).id
35 | else
36 | # make sure the given tag id exists in the given subscription
37 | event.subscription.tags.find(tag_id)
38 | end
39 | end
40 |
41 | def ensure_occurred_on
42 | self.occurred_on ||= event.occurred_on
43 | end
44 |
45 | def increment_tag_balance
46 | Tag.connection.update "UPDATE tags SET balance = balance + #{amount} WHERE id = #{tag_id}"
47 | end
48 |
49 | def decrement_tag_balance
50 | Tag.connection.update "UPDATE tags SET balance = balance - #{amount} WHERE id = #{tag_id}"
51 | end
52 | end
53 |
--------------------------------------------------------------------------------
/app/helpers/subscriptions_helper.rb:
--------------------------------------------------------------------------------
1 | module SubscriptionsHelper
2 | def blank_slate?
3 | subscription.accounts.empty?
4 | end
5 |
6 | def balance_cell(container, options={})
7 | balance = real_balance = container.balance
8 | if container.respond_to?(:available_balance)
9 | balance = container.available_balance
10 | end
11 |
12 | classes = %w(number)
13 | classes += Array(options[:classes]) if options[:classes]
14 | classes << "negative" if balance < 0
15 | classes << "current_balance"
16 |
17 | if container.is_a?(Account) && container.credit_card? && !container.limit.blank?
18 | percentage_used = container.limit.abs.to_i == 0 ? 100 :
19 | ((container.balance.abs.to_f / container.limit.abs.to_f) * 100).to_i
20 | classes << if percentage_used >= Account::DEFAULT_LIMIT_VALUES[:critical]: "critical"
21 | elsif percentage_used >= Account::DEFAULT_LIMIT_VALUES[:high]: "high"
22 | elsif percentage_used >= Account::DEFAULT_LIMIT_VALUES[:medium]: "medium"
23 | else "low" end
24 | end
25 |
26 | content = format_amount(balance)
27 | if real_balance != balance
28 | content = "(" << format_amount(real_balance) << ") #{content}"
29 | end
30 |
31 | content_tag(options.fetch(:tag, "td"), content, :class => classes.join(" "), :id => options[:id])
32 | end
33 |
34 | def format_amount(amount)
35 | amount = amount.abs
36 |
37 | dollars = amount / 100
38 | cents = amount % 100
39 |
40 | "$%s.%02d" % [number_with_delimiter(dollars), cents]
41 | end
42 | end
43 |
--------------------------------------------------------------------------------
/app/models/line_item.rb:
--------------------------------------------------------------------------------
1 | class LineItem < ActiveRecord::Base
2 | include Pageable
3 |
4 | VALID_ROLES = %w(payment_source
5 | credit_options
6 | transfer_to
7 | transfer_from
8 | deposit
9 | reallocate_to
10 | reallocate_from
11 | aside
12 | primary)
13 |
14 | # 'primary' role is omitted, as it is something of a special case
15 | VALID_ROLE_GROUPS = {
16 | "payment_source" => %w(payment_source credit_options aside),
17 | "credit_options" => %w(payment_source credit_options aside),
18 | "transfer_to" => %w(transfer_from transfer_to),
19 | "transfer_from" => %w(transfer_from transfer_to),
20 | "deposit" => %w(deposit),
21 | "reallocate_to" => %w(primary reallocate_to),
22 | "reallocate_from" => %w(reallocate_from primary),
23 | "aside" => %w(payment_source credit_options aside)
24 | }
25 |
26 | belongs_to :event
27 | belongs_to :account
28 | belongs_to :bucket
29 |
30 | after_create :increment_bucket_balance
31 | before_destroy :decrement_bucket_balance
32 |
33 | def to_xml(options={})
34 | options[:except] = Array(options[:except])
35 | options[:except].concat [:event_id, :occurred_on, :id]
36 | super(options)
37 | end
38 |
39 | protected
40 |
41 | def increment_bucket_balance
42 | bucket.update_attribute :balance, bucket.balance + amount
43 | end
44 |
45 | def decrement_bucket_balance
46 | bucket.update_attribute :balance, bucket.balance - amount
47 | end
48 | end
49 |
--------------------------------------------------------------------------------
/app/views/buckets/index.html.haml:
--------------------------------------------------------------------------------
1 | :javascript
2 | Buckets.newEventUrl = #{new_subscription_event_path(subscription).to_json}
3 |
4 | #data.content
5 | .navigation
6 | = link_to "Dashboard", subscription_path(subscription)
7 | = link_to "Transactions in #{h(account.name)}", account_path(account)
8 |
9 | %h2
10 | = balance_cell(account, :classes => %w(total balance), :tag => "span")
11 | == Buckets in #{h(account.name)}
12 |
13 | = link_to_function(filter, "Filters.display()", :id => :filter_nubbin)
14 |
15 | #filter_form{:style => "display: none"}
16 | - form_tag(account_buckets_path(account), :method => :get) do
17 | %p
18 | Include:
19 | %label
20 | = check_box_tag(:expenses, "1", filter.expenses?)
21 | Expenses
22 | = "|"
23 | %label
24 | = check_box_tag(:deposits, "1", filter.deposits?)
25 | Deposits
26 | = "|"
27 | %label
28 | = check_box_tag(:reallocations, "1", filter.reallocations?)
29 | Reallocations
30 | %p
31 | %label
32 | From:
33 | = calendar_date_select_tag :from, filter.from, :size => 9
34 | %label
35 | To:
36 | = calendar_date_select_tag :to, filter.to, :size => 9
37 |
38 | %p.actions
39 | = submit_tag "Apply these criteria", :name => nil
40 | or
41 | = link_to_function "cancel", "Filters.hide()"
42 | - if filter.any?
43 | or
44 | = link_to("reset this filter", account_buckets_path(account))
45 |
46 | %table#accounts_summary
47 | = render(buckets.sort_by(&:name))
48 |
--------------------------------------------------------------------------------
/CHANGELOG.rdoc:
--------------------------------------------------------------------------------
1 | === (unreleased)
2 |
3 | * Fix rounding error on ending balance for new statements [Cody Maggard]
4 |
5 | * Track limit on credit card accounts [Kieran Pilkington]
6 |
7 | * Change text on "save" button in event form to reflect edit vs. new [Kieran Pilkington]
8 |
9 | * Make "BucketWise" in header a link to the root path [Kieran Pilkington]
10 |
11 |
12 | === 1.1.0 / 14 May 2009
13 |
14 | * Recall previously entered transactions for easy entry of similar events [Jamis Buck]
15 |
16 | * Hide bucket list if there are less than 2 buckets for an account [Jamis Buck]
17 |
18 | * Increase "recent buckets" window size to 10 (from 5) [Jamis Buck]
19 |
20 | * Actor name autocompletion in forms [Jamis Buck]
21 |
22 | * Don't save line-items if they are blank [Jamis Buck]
23 |
24 | * Allow bucket-reallocation line items to be removed [Jamis Buck]
25 |
26 | * Show bucket split option when editing single-line-item events [Jamis Buck]
27 |
28 | * Fixed bug that wouldn't let you have a negative starting balance for new accounts [Jamis Buck]
29 |
30 | * Fixed bug that included an implicit "aside" bucket in the total bucket count [Tony Eichelberger]
31 |
32 | * Simple budget reporting [Jamis Buck]
33 |
34 | * Fix bug in account deletion that would leave (e.g.) transfer line items in an inconsistent state [Jamis Buck]
35 |
36 | * Account reconciliation [Jamis Buck]
37 |
38 | * filter password from logs [Jordan Brough]
39 |
40 | * fix incorrect rails gem reference in config/environment.rb [Jamis Buck]
41 |
42 | * fix reference to ~/.bucketwise/Capfile so it only loads if the file exists [Jamis Buck]
43 |
44 |
45 | === 1.0.0 / 20 Apr 2009
46 |
47 | * First release
48 |
--------------------------------------------------------------------------------
/test/fixtures/actors.yml:
--------------------------------------------------------------------------------
1 | # --------------------------------------------------------------
2 | # john
3 | # --------------------------------------------------------------
4 |
5 | john_starting_balance:
6 | subscription: john
7 | name: Starting balance
8 | sort_name: STARTING BALANCE
9 | created_at: <%= (60.days.ago + 1.hour).utc.to_s(:db) %>
10 | updated_at: <%= (60.days.ago + 1.hour).utc.to_s(:db) %>
11 |
12 | john_sandwich_central:
13 | subscription: john
14 | name: Sandwich Central
15 | sort_name: SANDWICH CENTRAL
16 | created_at: <%= 59.days.ago.utc.to_s(:db) %>
17 | updated_at: <%= 55.days.ago.utc.to_s(:db) %>
18 |
19 | john_mastercard_co:
20 | subscription: john
21 | name: MasterCard Co.
22 | sort_name: MASTERCARD CO.
23 | created_at: <%= 58.days.ago.utc.to_s(:db) %>
24 | updated_at: <%= 58.days.ago.utc.to_s(:db) %>
25 |
26 | john_bucket_reallocation:
27 | subscription: john
28 | name: Bucket reallocation
29 | sort_name: BUCKET REALLOCATION
30 | created_at: <%= 57.days.ago.utc.to_s(:db) %>
31 | updated_at: <%= 56.days.ago.utc.to_s(:db) %>
32 |
33 | john_auto_fuel:
34 | subscription: john
35 | name: Auto fuel
36 | sort_name: AUTO FUEL
37 | created_at: <%= 54.days.ago.utc.to_s(:db) %>
38 | updated_at: <%= 54.days.ago.utc.to_s(:db) %>
39 |
40 | # --------------------------------------------------------------
41 | # tim
42 | # --------------------------------------------------------------
43 |
44 | tim_starting_balance:
45 | subscription: tim
46 | name: Starting balance
47 | sort_name: STARTING BALANCE
48 | created_at: <%= (59.days.ago + 1.hour).utc.to_s(:db) %>
49 | updated_at: <%= (59.days.ago + 1.hour).utc.to_s(:db) %>
50 |
--------------------------------------------------------------------------------
/app/views/events/_form_section.html.haml:
--------------------------------------------------------------------------------
1 | %fieldset{:id => section, :style => section_visible_for_event?(section) ? nil : "display: none"}
2 | %legend= title
3 |
4 | %p
5 | = account_prompt
6 | = select_account(section, accounts, selected_account && selected_account.id)
7 | - if section_wants_check_options?(section)
8 | %span{:id => "#{section}.check_options", :style => visible?(check_options_visible_for?(section))}
9 | == Check ##{form.text_field :check_number, :size => 6}
10 | - if section_wants_repayment_options?(section)
11 | %span.repayment_options{:id => "#{section}.repayment_options", :style => visible?(repayment_options_visible_for?(section))}
12 | = "→ " + link_to_function("Specify repayment options", "Events.showRepaymentOptions('#{section}')")
13 |
14 | - if section_has_single_bucket?(section)
15 | %p{:id => "#{section}.single_bucket"}
16 | = single_bucket_prompt
17 | = select_bucket(section, :line_item => line_item_for_section(section))
18 | - if section == :credit_options
19 | %br/
20 | %small Money from this bucket will be transferred to an "Aside" bucket, to be used later to pay off this credit.
21 |
22 | %div{:id => "#{section}.multiple_buckets", :style => multi_bucket_visibility_for(section)}
23 | %p
24 | = multi_bucket_prompt
25 | %span{:id => "#{section}.unassigned"}
26 | %ol{:id => "#{section}.line_items"}
27 | - for_each_line_item_in(section) do |item|
28 | %li= render :partial => "events/line_item", :object => item, :locals => { :section => section }
29 | %p= link_to_function "More buckets, please!", "Events.addLineItemTo('#{section}', true)"
30 |
--------------------------------------------------------------------------------
/app/models/account_item.rb:
--------------------------------------------------------------------------------
1 | # AccountItems are almost the same as LineItems. However, a single Event may have
2 | # multiple LineItems associated with the same account (think "split transactions"),
3 | # and querying and aggregating those to show activity in an account would be more
4 | # work than it really needs to be. AccountItems are basically an optimization that
5 | # lets us show that activity more easily; each event will have exactly one AccountItem
6 | # record for each Account that the Event references.
7 |
8 | class AccountItem < ActiveRecord::Base
9 | include Pageable
10 |
11 | belongs_to :event
12 | belongs_to :account
13 | belongs_to :statement
14 |
15 | after_create :increment_balance
16 | before_destroy :decrement_balance
17 |
18 | named_scope :uncleared, lambda { |*args| AccountItem.options_for_uncleared(*args) }
19 |
20 | def self.options_for_uncleared(*args)
21 | raise ArgumentError, "too many arguments #{args.length} for 1" if args.length > 1
22 |
23 | options = args.first || {}
24 | raise ArgumentError, "expected Hash, got #{options.class}" unless options.is_a?(Hash)
25 | options = options.dup
26 |
27 | conditions = "statement_id IS NULL"
28 | parameters = []
29 |
30 | if options[:with]
31 | conditions = "(#{conditions} OR statement_id = ?)"
32 | parameters << options[:with]
33 | end
34 |
35 | { :conditions => [conditions, *parameters], :include => options[:include] }
36 | end
37 |
38 | protected
39 |
40 | def increment_balance
41 | account.update_attribute :balance, account.balance + amount
42 | end
43 |
44 | def decrement_balance
45 | account.update_attribute :balance, account.balance - amount
46 | end
47 | end
48 |
--------------------------------------------------------------------------------
/app/views/events/_form_general.html.haml:
--------------------------------------------------------------------------------
1 | %fieldset#general_information
2 | %legend General Information
3 |
4 | %p
5 | %span.expense_label When did this expense occur?
6 | %span.deposit_label When did this deposit occur?
7 | %span.transfer_label When did this transfer occur?
8 | = form.calendar_date_select :occurred_on, :size => 10
9 |
10 | %p
11 | %span.expense_label Who received the payment?
12 | %span.deposit_label Where did this deposit come from?
13 | %span.transfer_label What was this transfer for?
14 | = form.text_field :actor_name, :size => 30
15 | - if form.object.new_record?
16 | %span#recall_event{:style => "display: none"}= link_to_function "(recall)", "Events.recallEvent(#{subscription_events_path(subscription).to_json})"
17 |
18 | #event_actor_name_select.autocomplete_select{:style => "display: none"}
19 | = javascript_tag "Events.autocompleteActorField()"
20 |
21 | %p
22 | %span.expense_label How much was paid?
23 | %span.deposit_label How much was deposited?
24 | %span.transfer_label How much was transferred?
25 | == $#{text_field_tag :amount, event_amount_value, :size => 8, :id => "expense_total", :class => "number", :onchange => "Events.updateUnassigned()"}
26 |
27 | %p#memo_link{:style => visible?(!event_wants_memo?)}
28 | Got more to say?
29 | = link_to_function "Add a more verbose description...", "Events.revealMemo()"
30 |
31 | %p#memo{:style => visible?(event_wants_memo?)}
32 | Describe this transaction:
33 | %br
34 | = form.text_area :memo, :rows => 2, :cols => 70
35 |
--------------------------------------------------------------------------------
/TODO:
--------------------------------------------------------------------------------
1 | --------------------------------------------------------------------------
2 | KNOWN ISSUES
3 | --------------------------------------------------------------------------
4 |
5 | * After adding an event with a new tag, the tag autocompletion won't pick up the new tag until the page is reloaded.
6 | * After adding an event with a new actor, the actor autocompletion won't pick up the new actor until the page is reloaded.
7 | * Merging one bucket into another can result in events having two line-items referencing the same bucket.
8 |
9 | --------------------------------------------------------------------------
10 | FEATURES that would be nice to have someday (in no particular order)
11 | --------------------------------------------------------------------------
12 |
13 | * spinner for ajax actions
14 | * user info page (for password change)
15 | * password reset from login page
16 | * signup process
17 | * exception notifier (?)
18 | * better /subscriptions index
19 | * better 404 and 500 error pages
20 | * searching
21 | * reporting
22 | * scheduled transactions (occur automatically at specified intervals)
23 | * print stylesheet
24 | * oauth authentication for API
25 | * filter event views to show only events with a non-zero balance for the account in question
26 | * filter event views to show only deposits, or only expenses
27 | * full-text searching on the memo field
28 | * show bucket and account balances next to names in drop-downs
29 | * make bucket reallocation work from bucket perma
30 | * support for multiple 'aside' buckets in a single account
31 | * graphical icons to replace the textual icons for various actions
32 | * add/edit transactions from the reconciliation view
33 | * statement API
34 | * show percentage of credit limit consumed, rather than simply using text color
35 |
--------------------------------------------------------------------------------
/public/javascripts/tags.js:
--------------------------------------------------------------------------------
1 | var Tags = {
2 | rename: function(url, name, token) {
3 | new_name = prompt("Enter the name for this tag:", name);
4 | if(new_name && new_name != name) {
5 | params = encodeURIComponent("tag[name]") + "=" + encodeURIComponent(new_name) +
6 | "&authenticity_token=" + encodeURIComponent(token);
7 |
8 | new Ajax.Request(url, {
9 | asynchronous:true,
10 | evalScripts:true,
11 | method:'put',
12 | parameters:params
13 | });
14 | }
15 | },
16 |
17 | deleteTag: function() {
18 | if($('delete_form').down('fieldset')) {
19 | $('delete_form').show();
20 | $('data').hide();
21 | } else if(confirm("Are you sure want to delete this tag?")) {
22 | $('delete_form').down('form').submit();
23 | }
24 | },
25 |
26 | confirmDelete: function() {
27 | if($('mergeTagOption').down('input').checked) {
28 | if($('receiver_id').selectedIndex <= 0) {
29 | alert("If you want to merge tags, you must select a tag to merge with.");
30 | $('receiver_id').focus();
31 | return false;
32 | }
33 | } else {
34 | $('receiver_id').selectedIndex = 0;
35 | }
36 |
37 | return confirm("Are you sure you want to delete this tag?");
38 | },
39 |
40 | cancelDelete: function() {
41 | $('delete_form').down('form').reset();
42 | Tags.selectDeleteTag();
43 | $('delete_form').hide();
44 | $('data').show();
45 | },
46 |
47 | selectDeleteTag: function() {
48 | $('receiver_id').selectedIndex = 0;
49 | $('deleteTagOption').addClassName('selected');
50 | $('mergeTagOption').removeClassName('selected');
51 | },
52 |
53 | selectMergeTag: function() {
54 | $('mergeTagOption').addClassName('selected');
55 | $('deleteTagOption').removeClassName('selected');
56 | }
57 | }
58 |
--------------------------------------------------------------------------------
/app/models/query_filter.rb:
--------------------------------------------------------------------------------
1 | class QueryFilter
2 | attr_reader :from
3 | attr_reader :to
4 |
5 | def initialize(options={})
6 | @has_from = @has_to = false
7 |
8 | begin
9 | @from = Date.parse(options[:from])
10 | @has_from = true
11 | rescue
12 | @from = 3.months.ago.to_date
13 | end
14 |
15 | begin
16 | @to = Date.parse(options[:to])
17 | @has_to = true
18 | rescue
19 | @to = Date.today
20 | end
21 |
22 | @expenses = options[:expenses]
23 | @deposits = options[:deposits]
24 | @reallocations = options[:reallocations]
25 |
26 | if !@expenses && !@deposits && !@reallocations
27 | @expenses = @deposits = @reallocations = true
28 | end
29 | end
30 |
31 | def by_date?
32 | from? || to?
33 | end
34 |
35 | def from?
36 | @has_from
37 | end
38 |
39 | def to?
40 | @has_to
41 | end
42 |
43 | def by_type?
44 | !expenses? || !deposits? || !reallocations?
45 | end
46 |
47 | def expenses?
48 | @expenses
49 | end
50 |
51 | def deposits?
52 | @deposits
53 | end
54 |
55 | def reallocations?
56 | @reallocations
57 | end
58 |
59 | def any?
60 | by_date? || by_type?
61 | end
62 |
63 | def to_s
64 | description = "Filter"
65 |
66 | if any?
67 | description << ": "
68 |
69 | parts = [
70 | expenses? && "expenses",
71 | deposits? && "deposits",
72 | reallocations? && "reallocations"
73 | ].compact
74 |
75 | if parts.length == 3
76 | description << "all transactions"
77 | else
78 | description << parts.to_sentence
79 | end
80 |
81 | description << " from " << from.strftime("%Y-%m-%d") if from?
82 | description << " to " << to.strftime("%Y-%m-%d") if to?
83 | end
84 |
85 | return description
86 | end
87 | end
88 |
--------------------------------------------------------------------------------
/test/unit/tag_test.rb:
--------------------------------------------------------------------------------
1 | require 'test_helper'
2 |
3 | class TagTest < ActiveSupport::TestCase
4 | test "assimilate should raise exception when argument is same as self" do
5 | assert_no_difference "Tag.count" do
6 | assert_raise(ActiveRecord::RecordNotSaved) do
7 | tags(:john_lunch).assimilate(tags(:john_lunch))
8 | end
9 | end
10 |
11 | assert_equal tags(:john_lunch), tagged_items(:john_lunch_lunch).tag
12 | end
13 |
14 | test "assimilate should update all tagged items of argument to refer to self" do
15 | items = tags(:john_lunch).tagged_items
16 | assert items.any?
17 |
18 | tags(:john_fuel).assimilate(tags(:john_lunch))
19 | assert items.all? { |item| item.reload.tag == tags(:john_fuel) }
20 | end
21 |
22 | test "assimilate should update balance to include amounts of tagged items" do
23 | balance = tags(:john_fuel).balance
24 | tags(:john_fuel).assimilate(tags(:john_lunch))
25 |
26 | assert_equal balance + tags(:john_lunch).balance, tags(:john_fuel).balance
27 | assert_equal balance + tags(:john_lunch).balance, tags(:john_fuel, :reload).balance
28 | end
29 |
30 | test "assimilate should destroy argument but not tagged items" do
31 | items = tags(:john_lunch).tagged_items
32 | assert items.any?
33 |
34 | tags(:john_fuel).assimilate(tags(:john_lunch))
35 | assert !Tag.exists?(tags(:john_lunch).id)
36 | assert items.all? { |item| TaggedItem.exists?(item.id) }
37 | end
38 |
39 | test "duplicates should be allowed for different subscriptions" do
40 | assert_difference "Tag.count" do
41 | subscriptions(:tim).tags.create(:name => "lunch")
42 | end
43 | end
44 |
45 | test "duplicates should be forbidden within a subscription" do
46 | assert_no_difference "Tag.count" do
47 | tag = subscriptions(:john).tags.create(:name => "lunch")
48 | assert tag.errors.on(:name)
49 | end
50 | end
51 | end
52 |
--------------------------------------------------------------------------------
/test/unit/user_test.rb:
--------------------------------------------------------------------------------
1 | require 'test_helper'
2 |
3 | class UserTest < ActiveSupport::TestCase
4 | test "authenticate should return user if password is correct" do
5 | assert_equal users(:john), User.authenticate("jjohnson", "testing")
6 | end
7 |
8 | test "authenticate should return nil if user_name is incorrect" do
9 | assert_nil User.authenticate("john", "testing")
10 | end
11 |
12 | test "authenticate should return nil if password is incorrect" do
13 | assert_nil User.authenticate("jjohnson", "test")
14 | end
15 |
16 | test "creating new user should set salt and hash password" do
17 | user = subscriptions(:john).users.create(:name => "Tom Thompson",
18 | :email => "tthompson@domain.test", :user_name => "tthompson",
19 | :password => "thePassword")
20 | assert !user.password_hash.blank?
21 | assert !user.salt.blank?
22 | assert_equal user, User.authenticate("tthompson", "thePassword")
23 | end
24 |
25 | test "updating user's password should change salt and hash new password" do
26 | old_salt = users(:john).salt
27 | old_password_hash = users(:john).password_hash
28 |
29 | users(:john).update_attribute :password, "vamoose!"
30 | assert_not_equal users(:john).salt, old_salt
31 | assert_not_equal users(:john).password_hash, old_password_hash
32 |
33 | assert_nil User.authenticate("jjohnson", "testing")
34 | assert_equal users(:john), User.authenticate("jjohnson", "vamoose!")
35 | end
36 |
37 | test "creating new user with duplicate user name should fail" do
38 | begin
39 | subscriptions(:john).users.create!(:name => "James Johnson",
40 | :email => "james.johnson@domain.test", :user_name => "jjohnson",
41 | :password => "ponies!")
42 | rescue ActiveRecord::RecordInvalid => error
43 | assert error.record.errors.on(:user_name)
44 | else
45 | flunk "expected create to fail"
46 | end
47 | end
48 |
49 | test "user as xml should not emit password_hash or salt" do
50 | xml = users(:john).to_xml
51 | user = Hash.from_xml(xml)["user"]
52 | assert !user.key?("password_hash")
53 | assert !user.key?("salt")
54 | end
55 | end
56 |
--------------------------------------------------------------------------------
/app/views/accounts/_form.html.haml:
--------------------------------------------------------------------------------
1 | - form_for(:account, @account, :url => subscription_accounts_path(subscription), :html => { :onsubmit => "if(!Accounts.submit()) return false", :id => "new_account_form" }) do |form|
2 | - if form.object && !form.object.errors.empty?
3 | %fieldset.errors
4 | %legend Errors
5 |
6 | %p The account could not be saved because of the following error(s):
7 | %ul
8 | - form.object.errors.each_full do |message|
9 | %li&= message
10 |
11 | %fieldset
12 | %legend Account info
13 |
14 | %p
15 | %label{:for => "account_name"} Choose a name for this account:
16 | = form.text_field :name, :class => "text"
17 |
18 | %p
19 | %label
20 | What kind of account is this?
21 | = form.select :role, [["Checking", "checking"], ["Credit card", "credit-card"], ["Other", "other"]], {}, :onchange => "Accounts.showOrHideCreditLimit(this.value);"
22 |
23 | %p{:style => 'display: none;', :id => 'credit_limit_div'}
24 | %label
25 | What is the credit limit:
26 | == $#{form.text_field :limit, :class => "number", :size => 8}
27 |
28 | - if form.object.nil? || form.object.new_record?
29 | %fieldset
30 | %legend Starting balance
31 |
32 | .instructions
33 | %p Take a look at your latest statement for this account.
34 | %p Note the current balance from the statement, as |
35 | well as the date the statement was issued, and enter |
36 | them below. |
37 | = hidden_field_tag "account[starting_balance][amount]", ""
38 |
39 | %p
40 | %label
41 | What is the current balance:
42 | == $#{text_field_tag :current_balance, account_starting_balance_amount, :class => "number", :size => 8}
43 |
44 | %p
45 | %label
46 | When was the statement issued:
47 | = calendar_date_select_tag "account[starting_balance][occurred_on]", account_starting_balance_occurred_on, :size => 9
48 |
49 | %p
50 | = submit_tag "Create this account"
51 | or
52 | = link_to_function "cancel", "Accounts.hideForm()"
53 |
--------------------------------------------------------------------------------
/public/javascripts/buckets.js:
--------------------------------------------------------------------------------
1 | var Buckets = {
2 | onMouseOver: function(id) {
3 | var nubbin = $('nubbin_bucket_' + id);
4 | var offset = nubbin.up("tr").cumulativeOffset();
5 |
6 | nubbin.show();
7 | nubbin.style.left = (offset.left - nubbin.getWidth()) + "px";
8 | },
9 |
10 | onMouseOut: function(id) {
11 | $('nubbin_bucket_' + id).hide();
12 | },
13 |
14 | configureEvent: function() {
15 | Events.defaultDate = new Date().toFormattedString();
16 | Events.defaultActor = "Bucket reallocation";
17 | },
18 |
19 | transferTo: function(account_id, bucket_id) {
20 | if(Buckets.newEventUrl) {
21 | window.location = Buckets.newEventUrl + "?role=reallocation&to=" + bucket_id;
22 | } else {
23 | Buckets.configureEvent();
24 | Events.revealReallocationForm('to', account_id, bucket_id);
25 | }
26 | },
27 |
28 | transferFrom: function(account_id, bucket_id) {
29 | if(Buckets.newEventUrl) {
30 | window.location = Buckets.newEventUrl + "?role=reallocation&from=" + bucket_id;
31 | } else {
32 | Buckets.configureEvent();
33 | Events.revealReallocationForm('from', account_id, bucket_id);
34 | }
35 | },
36 |
37 | view: function() {
38 | if($$('tr.bucket').any()) {
39 | return 'index';
40 | } else {
41 | return 'perma';
42 | }
43 | },
44 |
45 | rename: function(url, name, token) {
46 | new_name = prompt("Enter the name for this bucket:", name);
47 | if(new_name && new_name != name) {
48 | params = encodeURIComponent("bucket[name]") + "=" + encodeURIComponent(new_name) +
49 | "&authenticity_token=" + encodeURIComponent(token) + "&view=" + Buckets.view();
50 |
51 | new Ajax.Request(url, {
52 | asynchronous:true,
53 | evalScripts:true,
54 | method:'put',
55 | parameters:params
56 | });
57 | }
58 | },
59 |
60 | deleteBucket: function() {
61 | $('data').hide();
62 | $('delete_form').show();
63 | },
64 |
65 | confirmDelete: function() {
66 | return confirm("Are you sure you want to delete this bucket?");
67 | },
68 |
69 | cancelDelete: function() {
70 | $('delete_form').hide();
71 | $('data').show();
72 | }
73 | }
74 |
--------------------------------------------------------------------------------
/test/fixtures/account_items.yml:
--------------------------------------------------------------------------------
1 | # --------------------------------------------------------------
2 | # john
3 | # --------------------------------------------------------------
4 |
5 | john_checking_starting_balance:
6 | event: john_checking_starting_balance
7 | account: john_checking
8 | amount: 100000
9 | occurred_on: <%= 60.days.ago.to_date.to_s(:db) %>
10 | statement: john
11 |
12 | john_lunch_mastercard:
13 | event: john_lunch
14 | account: john_mastercard
15 | amount: -775
16 | occurred_on: <%= 59.days.ago.utc.to_s(:db) %>
17 |
18 | john_lunch_checking:
19 | event: john_lunch
20 | account: john_checking
21 | amount: 0
22 | occurred_on: <%= 59.days.ago.utc.to_s(:db) %>
23 |
24 | john_bill_pay_checking:
25 | event: john_bill_pay
26 | account: john_checking
27 | amount: -775
28 | occurred_on: <%= 58.days.ago.utc.to_s(:db) %>
29 | statement: john
30 |
31 | john_bill_pay_mastercard:
32 | event: john_bill_pay
33 | account: john_mastercard
34 | amount: 775
35 | occurred_on: <%= 58.days.ago.utc.to_s(:db) %>
36 |
37 | john_reallocate_from_checking:
38 | event: john_reallocate_from
39 | account: john_checking
40 | amount: 0
41 | occurred_on: <%= 57.days.ago.utc.to_s(:db) %>
42 |
43 | john_reallocate_to_checking:
44 | event: john_reallocate_to
45 | account: john_checking
46 | amount: 0
47 | occurred_on: <%= 56.days.ago.utc.to_s(:db) %>
48 |
49 | john_lunch_again_mastercard:
50 | event: john_lunch_again
51 | account: john_mastercard
52 | amount: -1025
53 | occurred_on: <%= 55.days.ago.utc.to_s(:db) %>
54 |
55 | john_lunch_again_checking:
56 | event: john_lunch_again
57 | account: john_checking
58 | amount: 0
59 | occurred_on: <%= 55.days.ago.utc.to_s(:db) %>
60 |
61 | john_bare_mastercard:
62 | event: john_bare_mastercard
63 | account: john_mastercard
64 | amount: -1500
65 | occurred_on: <%= 54.days.ago.utc.to_s(:db) %>
66 |
67 | # --------------------------------------------------------------
68 | # tim
69 | # --------------------------------------------------------------
70 |
71 | tim_checking_starting_balance:
72 | event: tim_checking_starting_balance
73 | account: tim_checking
74 | amount: 125000
75 | occurred_on: <%= 59.days.ago.to_date.to_s(:db) %>
76 |
--------------------------------------------------------------------------------
/public/javascripts/money.js:
--------------------------------------------------------------------------------
1 | var Money = {
2 | dollars: function(cents, keepNegative) {
3 | cents = keepNegative ? cents : Math.abs(cents);
4 | return (cents / 100).toFixed(2);
5 | },
6 |
7 | parse: function(field, keepNegative) {
8 | return Money.parseValue($F(field), keepNegative);
9 | },
10 |
11 | /* don't want to use parseFloat, because we can be subject to
12 | * floating point round-off */
13 | parseValue: function(string, keepNegative) {
14 | var value = string.gsub(/[^-+\d.]/, "");
15 | var match = value.match(/^([-+]?)(\d*)(?:\.(\d*))?$/);
16 |
17 | if(!match) return 0;
18 |
19 | var sign = ((match[1] == "-") && keepNegative) ? -1 : 1;
20 | if(match[2].length > 0)
21 | var dollars = parseInt(match[2]);
22 | else
23 | var dollars = 0;
24 |
25 | if(match[3] && match[3].length > 0) {
26 | var cents_magnitude = Math.pow(10, match[3].length);
27 | var cents_text = match[3].sub(/^0+/, "");
28 | if(cents_text.blank()) cents_text = "0";
29 | var cents = Math.round(parseInt(cents_text) * 100 / cents_magnitude);
30 | } else {
31 | var cents = 0;
32 | }
33 |
34 | return sign * (dollars * 100 + cents);
35 | },
36 |
37 | format: function(field) {
38 | return Money.formatValue(Money.parse(field, true));
39 | },
40 |
41 | formatValue: function(cents) {
42 | var sign = cents < 0 ? -1 : 1;
43 | var source = String(Math.abs(cents));
44 | var result;
45 |
46 | if(source.length > 2) {
47 | result = "." + source.slice(-2);
48 | source = source.slice(0,-2);
49 | } else if(source.length == 2) {
50 | result = "." + source;
51 | source = "";
52 | } else if(source.length == 1) {
53 | result = ".0" + source;
54 | source = "";
55 | } else {
56 | result = ".00";
57 | }
58 |
59 | while(source.length > 3) {
60 | result = "," + source.slice(-3) + result;
61 | source = source.slice(0,-3);
62 | }
63 |
64 | if(source.length > 0) {
65 | result = source + result;
66 | } else if(result[0] == ".") {
67 | result = "0" + result;
68 | }
69 |
70 | if(sign < 0) {
71 | result = "-" + result;
72 | }
73 |
74 | return result;
75 | }
76 | }
77 |
--------------------------------------------------------------------------------
/app/controllers/application_controller.rb:
--------------------------------------------------------------------------------
1 | # Filters added to this controller apply to all controllers in the application.
2 | # Likewise, all the methods added will be available for all controllers.
3 |
4 | class ApplicationController < ActionController::Base
5 | include OptionHandler
6 |
7 | helper :all
8 |
9 | protect_from_forgery
10 |
11 | before_filter :authenticate
12 |
13 | filter_parameter_logging :password
14 |
15 | rescue_from ActiveRecord::RecordNotFound, :with => :render_404
16 |
17 | protected
18 |
19 | attr_reader :subscription, :user
20 | helper_method :subscription, :user
21 |
22 | def find_subscription
23 | @subscription = user.subscriptions.find(params[:subscription_id] || params[:id])
24 | end
25 |
26 | def authenticate
27 | if session[:user_id]
28 | @user = User.find(session[:user_id])
29 | elsif via_api?
30 | authenticate_or_request_with_http_basic do |user_name, password|
31 | @user = User.authenticate(user_name, password)
32 | end
33 | else
34 | redirect_to(new_session_url)
35 | end
36 | end
37 |
38 | def current_location
39 | controller_name
40 | end
41 | helper_method :current_location
42 |
43 | def render_404
44 | respond_to do |format|
45 | format.html { render :file => "#{RAILS_ROOT}/public/404.html", :status => :not_found }
46 | format.xml { head :not_found }
47 | end
48 | end
49 |
50 | def via_api?
51 | request.format == Mime::XML
52 | end
53 | helper_method :via_api?
54 |
55 | private
56 |
57 | def self.acceptable_includes(*list)
58 | includes = read_inheritable_attribute(:acceptable_includes) || []
59 |
60 | if list.any?
61 | includes = Set.new(list.map(&:to_s)) + includes
62 | write_inheritable_attribute(:acceptable_includes, includes)
63 | end
64 |
65 | includes
66 | end
67 |
68 | def acceptable_includes
69 | self.class.acceptable_includes
70 | end
71 |
72 | def eager_options(options={})
73 | if params[:include]
74 | list = acceptable_includes & params[:include].split(/,/)
75 | append_to_options(options, :include, list.map(&:to_sym)) if list.any?
76 | end
77 |
78 | return options
79 | end
80 | end
81 |
--------------------------------------------------------------------------------
/test/functional/subscriptions_controller_test.rb:
--------------------------------------------------------------------------------
1 | require 'test_helper'
2 |
3 | class SubscriptionsControllerTest < ActionController::TestCase
4 | setup :login_default_user
5 |
6 | test "index should redirect to sole subscription if there is only one" do
7 | login! :tim
8 | get :index
9 | assert_redirected_to subscription_url(subscriptions(:tim))
10 | end
11 |
12 | test "index should list all subscriptions if there are many" do
13 | get :index
14 | assert_response :success
15 | assert_template "subscriptions/index"
16 | end
17 |
18 | test "index should not include any subscriptions not accessible to user" do
19 | get :index
20 | assert_response :success
21 | assert_select "li#subscription_#{subscriptions(:john).id}"
22 | assert_select "li#subscription_#{subscriptions(:john_family).id}"
23 | assert_select "li#subscription_#{subscriptions(:tim).id}", false
24 | end
25 |
26 | test "show should 404 for invalid subscription" do
27 | assert !Subscription.exists?(1)
28 | get :show, :id => 1
29 | assert_response :missing
30 | end
31 |
32 | test "show should 404 for inaccessible subscription" do
33 | get :show, :id => subscriptions(:tim).id
34 | assert_response :missing
35 | end
36 |
37 | test "show should display dashboard for selected subscription" do
38 | get :show, :id => subscriptions(:john).id
39 | assert_response :success
40 | assert_template "subscriptions/show"
41 | assert_equal subscriptions(:john), assigns(:subscription)
42 | end
43 |
44 | # == API tests ========================================================================
45 |
46 | test "index via API should return list of all subscriptions available to user" do
47 | get :index, :format => "xml"
48 | assert_response :success
49 | xml = Hash.from_xml(@response.body)
50 | assert xml.key?("subscriptions")
51 | end
52 |
53 | test "show via API should return 404 for inaccessible subscription" do
54 | get :show, :id => subscriptions(:tim).id, :format => "xml"
55 | assert_response :missing
56 | end
57 |
58 | test "show should return requested subscription record" do
59 | get :show, :id => subscriptions(:john).id, :format => "xml"
60 | assert_response :success
61 | xml = Hash.from_xml(@response.body)
62 | assert xml.key?("subscription")
63 | end
64 | end
65 |
--------------------------------------------------------------------------------
/public/javascripts/statements.js:
--------------------------------------------------------------------------------
1 | var Statements = {
2 | clickItem: function(id) {
3 | $('check_account_item_' + id).click();
4 | },
5 |
6 | toggleCleared: function(id) {
7 | var checked = $('check_account_item_' + id).checked;
8 | var amount = parseInt($('amount_account_item_' + id).innerHTML);
9 | var subtotalField = $('account_item_' + id).up('fieldset').down('.subtotal_dollars');
10 | var subtotal = Money.parseValue(subtotalField.innerHTML, true);
11 |
12 | if(checked) {
13 | $('account_item_' + id).addClassName('cleared');
14 | subtotalField.innerHTML = "$" + Money.formatValue(subtotal + amount);
15 | } else {
16 | $('account_item_' + id).removeClassName('cleared');
17 | subtotalField.innerHTML = "$" + Money.formatValue(subtotal - amount);
18 | }
19 |
20 | Statements.updateBalances();
21 | },
22 |
23 | startingBalance: function() {
24 | if(!Statements.cachedBalance)
25 | Statements.cachedBalance = Money.parseValue($('starting_balance').innerHTML, true);
26 | return Statements.cachedBalance;
27 | },
28 |
29 | endingBalance: function() {
30 | return Money.parse($('statement_ending_balance'), true);
31 | },
32 |
33 | settled: function() {
34 | return $$('.subtotal_dollars').inject(0, function(sum, span) {
35 | return sum + Money.parseValue(span.innerHTML, true);
36 | });
37 | },
38 |
39 | remaining: function() {
40 | return Statements.startingBalance() + Statements.settled() - Statements.endingBalance();
41 | },
42 |
43 | updateBalances: function() {
44 | var ending = $('statement_ending_balance');
45 | ending.value = Money.format(ending);
46 |
47 | var remaining = Statements.remaining();
48 | var remainingText = Money.formatValue(remaining);
49 |
50 | ['deposits', 'checks', 'expenses'].each(function(section) {
51 | if($(section)) {
52 | var span = $$("#" + section + " .remaining_dollars").first();
53 |
54 | if(remaining == 0)
55 | span.addClassName("balanced");
56 | else
57 | span.removeClassName("balanced");
58 |
59 | span.innerHTML = "$" + remainingText;
60 | }
61 | })
62 |
63 | if(remaining == 0) {
64 | $('balanced').show();
65 | $('actions').hide();
66 | } else {
67 | $('balanced').hide();
68 | $('actions').show();
69 | }
70 | }
71 | }
72 |
--------------------------------------------------------------------------------
/app/views/statements/edit.html.haml:
--------------------------------------------------------------------------------
1 | #data.content
2 | %h2== Balance your statement
3 |
4 | .statement.edit.form
5 |
6 | - form_for(statement) do |form|
7 |
8 | %table.general
9 | %tr
10 | %th.occurred_on Statement date
11 | %th.starting_balance Starting balance
12 | %th.ending_balance Ending balance
13 | %tr
14 | %td.occurred_on= form.calendar_date_select :occurred_on, :size => 10
15 | %td#starting_balance.starting_balance.number= format_cents(statement.starting_balance)
16 | %td.ending_balance= "$" + form.text_field(:ending_balance, :size => 8, :class => "number", :value => format_cents(statement.ending_balance, :unit => ""), :onchange => "Statements.updateBalances()")
17 |
18 | - if uncleared.deposits.any?
19 | %fieldset#deposits
20 | %legend Deposits
21 |
22 | .uncleared.deposits
23 | %table= render :partial => "statements/uncleared", :collection => uncleared.deposits
24 |
25 | = render :partial => "statements/subtotal", :object => statement.account_items.deposits.sum(&:amount)
26 |
27 | - if uncleared.checks.any?
28 | %fieldset#checks
29 | %legend Checks
30 |
31 | .uncleared.checks
32 | %table= render :partial => "statements/uncleared", :collection => uncleared.checks
33 |
34 | = render :partial => "statements/subtotal", :object => statement.account_items.checks.sum(&:amount)
35 |
36 | - if uncleared.expenses.any?
37 | %fieldset#expenses
38 | %legend Other expenses
39 |
40 | .uncleared.expenses
41 | %table= render :partial => "statements/uncleared", :collection => uncleared.expenses
42 |
43 | = render :partial => "statements/subtotal", :object => statement.account_items.expenses.sum(&:amount)
44 |
45 | #balanced{:style => visible?(statement.balanced?)}
46 | %h3 Congratulations!
47 |
48 | %p Your records exactly match your account statement, and everything balances.
49 |
50 | %p= form.submit "Close out this statement"
51 |
52 | %p#actions{:style => visible?(!statement.balanced?)}
53 | = form.submit "Save for later"
54 | or
55 | = link_to("abort this reconciliation", statement_path(statement), :method => :delete, :confirm => "Are you sure you want to discard this reconciliation?")
56 |
--------------------------------------------------------------------------------
/test/fixtures/buckets.yml:
--------------------------------------------------------------------------------
1 | # --------------------------------------------------------------
2 | # john
3 | # --------------------------------------------------------------
4 |
5 | john_checking_general:
6 | account: john_checking
7 | author: john
8 | name: General
9 | role: default
10 | balance: 98000
11 | created_at: <%= (60.days.ago + 1.hour).utc.to_s(:db) %>
12 | updated_at: <%= (60.days.ago + 1.hour).utc.to_s(:db) %>
13 |
14 | john_checking_groceries:
15 | account: john_checking
16 | author: john
17 | name: Groceries
18 | role: ~
19 | balance: 0
20 | created_at: <%= (60.days.ago + 2.hours).utc.to_s(:db) %>
21 | updated_at: <%= (60.days.ago + 2.hours).utc.to_s(:db) %>
22 |
23 | john_checking_household:
24 | account: john_checking
25 | author: john
26 | name: Household
27 | role: ~
28 | balance: 0
29 | created_at: <%= (60.days.ago + 2.hours).utc.to_s(:db) %>
30 | updated_at: <%= (60.days.ago + 2.hours).utc.to_s(:db) %>
31 |
32 | john_checking_dining:
33 | account: john_checking
34 | author: john
35 | name: Dining
36 | role: ~
37 | balance: 200
38 | created_at: <%= (60.days.ago + 2.hours).utc.to_s(:db) %>
39 | updated_at: <%= (60.days.ago + 2.hours).utc.to_s(:db) %>
40 |
41 | john_checking_aside:
42 | account: john_checking
43 | author: john
44 | name: Aside
45 | role: aside
46 | balance: 1025
47 | created_at: <%= (60.days.ago + 3.hours).utc.to_s(:db) %>
48 | updated_at: <%= (60.days.ago + 3.hours).utc.to_s(:db) %>
49 |
50 | john_mastercard_general:
51 | account: john_mastercard
52 | author: john
53 | name: General
54 | role: default
55 | balance: -2525
56 | created_at: <%= (60.days.ago + 2.hours).utc.to_s(:db) %>
57 | updated_at: <%= (60.days.ago + 2.hours).utc.to_s(:db) %>
58 |
59 | john_savings_general:
60 | account: john_savings
61 | author: john
62 | name: General
63 | role: default
64 | balance: 0
65 | created_at: <%= (60.days.ago + 3.hours).utc.to_s(:db) %>
66 | updated_at: <%= (60.days.ago + 3.hours).utc.to_s(:db) %>
67 |
68 | # --------------------------------------------------------------
69 | # tim
70 | # --------------------------------------------------------------
71 |
72 | tim_checking_general:
73 | account: tim_checking
74 | author: tim
75 | name: General
76 | role: default
77 | balance: 125000
78 | created_at: <%= (59.days.ago + 1.hour).utc.to_s(:db) %>
79 | updated_at: <%= (59.days.ago + 1.hour).utc.to_s(:db) %>
80 |
81 |
--------------------------------------------------------------------------------
/lib/tasks/data.rake:
--------------------------------------------------------------------------------
1 | namespace :data do
2 | namespace :subscription do
3 | desc "Dump all data for a single subscription"
4 | task :dump => :environment do
5 | id = ENV['ID'] or abort "please specify the subscription id via the ID env var"
6 | subscription = Subscription.find(id)
7 |
8 | data = {}
9 |
10 | data[:tags] = subscription.tags.map(&:attributes)
11 | data[:accounts] = subscription.accounts.map(&:attributes)
12 | data[:actors] = subscription.actors.map(&:attributes)
13 | data[:events] = subscription.events.map(&:attributes)
14 | data[:user_subscriptions] = subscription.user_subscriptions.map(&:attributes)
15 |
16 | data[:statements] = subscription.accounts.map(&:statements).flatten.map(&:attributes)
17 | data[:buckets] = subscription.accounts.map(&:buckets).flatten.map(&:attributes)
18 | data[:line_items] = subscription.events.map(&:line_items).flatten.map(&:attributes)
19 | data[:account_items] = subscription.events.map(&:account_items).flatten.map(&:attributes)
20 | data[:tagged_items] = subscription.events.map(&:tagged_items).flatten.map(&:attributes)
21 |
22 | data[:subscription] = subscription.attributes
23 |
24 | File.open("#{id}.yml", "w") { |f| f.write(data.to_yaml) }
25 | end
26 |
27 | desc "Restores data for the given subscription file"
28 | task :load => :environment do
29 | abort "please confirm (via the CONFIRM env var) that you really want to do this" unless ENV['CONFIRM']
30 |
31 | file = ENV['FILE'] or abort "please specify the dump file via the FILE env var"
32 | data = YAML.load_file(file)
33 |
34 | insert = Proc.new do |table, record|
35 | c = ActiveRecord::Base.connection
36 | columns = record.keys.map { |name| c.quote_column_name(name) }
37 | values = record.values.map { |value| c.quote(value) }
38 | c.insert("INSERT INTO #{table} (#{columns.join(",")}) VALUES (#{values.join(",")})")
39 | end
40 |
41 | Subscription.transaction do
42 | subscription = Subscription.find_by_id(data[:subscription]['id'])
43 | subscription.destroy if subscription
44 |
45 | insert.call("subscriptions", data[:subscription])
46 | %w(tags accounts actors events user_subscriptions statements buckets line_items account_items tagged_items).each do |table|
47 | Array(data[table.to_sym]).each do |record|
48 | insert.call(table, record)
49 | end
50 | end
51 | end
52 | end
53 | end
54 | end
55 |
--------------------------------------------------------------------------------
/app/controllers/accounts_controller.rb:
--------------------------------------------------------------------------------
1 | class AccountsController < ApplicationController
2 | acceptable_includes :author, :buckets
3 |
4 | before_filter :find_account, :except => %w(index create new)
5 | before_filter :find_subscription, :only => %w(index create new)
6 |
7 | def index
8 | respond_to do |format|
9 | format.xml { render :xml => subscription.accounts.to_xml(eager_options(:root => "accounts")) }
10 | end
11 | end
12 |
13 | def show
14 | respond_to do |format|
15 | format.html do
16 | @page = (params[:page] || 0).to_i
17 | @more_pages, @items = account.account_items.page(@page)
18 | end
19 |
20 | format.xml { render :xml => account.to_xml(eager_options) }
21 | end
22 | end
23 |
24 | def new
25 | respond_to do |format|
26 | format.html
27 | format.xml { render :xml => Account.template.to_xml }
28 | end
29 | end
30 |
31 | def create
32 | @account = subscription.accounts.create!(params[:account], :author => user)
33 | respond_to do |format|
34 | format.html { redirect_to(subscription_url(subscription)) }
35 | format.xml { render :xml => @account.to_xml, :status => :created, :location => account_url(@account) }
36 | end
37 | rescue ActiveRecord::RecordInvalid => error
38 | @account = error.record
39 | respond_to do |format|
40 | format.html { render :action => "new" }
41 | format.xml { render :status => :unprocessable_entity, :xml => @account.errors.to_xml }
42 | end
43 | end
44 |
45 | def destroy
46 | account.destroy
47 | respond_to do |format|
48 | format.html { redirect_to(subscription_url(subscription)) }
49 | format.xml { head :ok }
50 | end
51 | end
52 |
53 | def update
54 | account.update_attributes!(params[:account])
55 |
56 | respond_to do |format|
57 | format.js
58 | format.xml { render :xml => account.to_xml }
59 | end
60 | rescue ActiveRecord::RecordInvalid
61 | respond_to do |format|
62 | format.js
63 | format.xml { render :status => :unprocessable_entity, :xml => account.errors.to_xml }
64 | end
65 | end
66 |
67 | protected
68 |
69 | attr_reader :account
70 | helper_method :account
71 |
72 | def find_account
73 | @account = Account.find(params[:id])
74 | @subscription = user.subscriptions.find(@account.subscription_id)
75 | end
76 |
77 | def current_location
78 | if account
79 | "accounts/%d" % account.id
80 | else
81 | super
82 | end
83 | end
84 | end
85 |
--------------------------------------------------------------------------------
/app/controllers/tags_controller.rb:
--------------------------------------------------------------------------------
1 | class TagsController < ApplicationController
2 | before_filter :find_subscription, :only => %w(index new create)
3 | before_filter :find_tag, :except => %w(index new create)
4 |
5 | def index
6 | respond_to do |format|
7 | format.xml { render :xml => subscription.tags.to_xml(:root => "tags") }
8 | end
9 | end
10 |
11 | def show
12 | respond_to do |format|
13 | format.html do
14 | @page = (params[:page] || 0).to_i
15 | @more_pages, @items = tag_ref.tagged_items.page(@page)
16 | end
17 | format.xml { render :xml => tag_ref }
18 | end
19 | end
20 |
21 | def new
22 | respond_to { |format| format.xml { render :xml => Tag.template } }
23 | end
24 |
25 | def create
26 | respond_to do |format|
27 | format.xml do
28 | @tag_ref = subscription.tags.create!(params[:tag])
29 | render :xml => @tag_ref, :status => :created, :location => tag_url(@tag_ref)
30 | end
31 | end
32 | rescue ActiveRecord::RecordInvalid => error
33 | respond_to do |format|
34 | format.xml { render :status => :unprocessable_entity, :xml => error.record.errors }
35 | end
36 | end
37 |
38 | def update
39 | tag_ref.update_attributes!(params[:tag])
40 | respond_to do |format|
41 | format.js
42 | format.xml { render :xml => tag_ref }
43 | end
44 | rescue ActiveRecord::RecordInvalid
45 | respond_to do |format|
46 | format.js
47 | format.xml { render :status => :unprocessable_entity, :xml => tag_ref.errors }
48 | end
49 | end
50 |
51 | def destroy
52 | if params[:receiver_id].present?
53 | receiver = subscription.tags.find(params[:receiver_id])
54 | receiver.assimilate(tag_ref)
55 | else
56 | tag_ref.destroy
57 | end
58 |
59 | respond_to do |format|
60 | format.html { redirect_to(receiver || subscription) }
61 | format.xml { head :ok }
62 | end
63 | rescue ActiveRecord::RecordNotSaved => error
64 | head :unprocessable_entity
65 | end
66 |
67 | protected
68 |
69 | # can't call it 'tag' because that conflicts with the Rails 'tag()'
70 | # helper method. 'tag_ref' is lame, but sufficient.
71 | attr_reader :tag_ref
72 | helper_method :tag_ref
73 |
74 | def find_tag
75 | @tag_ref = Tag.find(params[:id])
76 | @subscription = user.subscriptions.find(@tag_ref.subscription_id)
77 | end
78 |
79 | def current_location
80 | if tag_ref
81 | "tags/%d" % tag_ref.id
82 | else
83 | super
84 | end
85 | end
86 | end
87 |
--------------------------------------------------------------------------------
/lib/tasks/user.rake:
--------------------------------------------------------------------------------
1 | namespace :user do
2 | desc "Create a new user."
3 | task :create => :environment do
4 | require 'highline'
5 |
6 | ui = HighLine.new
7 |
8 | name = ui.ask("Name: ")
9 | email = ui.ask("E-mail: ")
10 | user_name = ui.ask("User name: ")
11 | password = ui.ask("Password: ")
12 |
13 | user = User.create!(:name => name, :email => email,
14 | :user_name => user_name, :password => password)
15 |
16 | puts "User `#{user_name}' created: ##{user.id}"
17 | end
18 |
19 | desc "List users (PAGE env var selects which page of users)"
20 | task :list => :environment do
21 | page = ENV['PAGE'].to_i
22 |
23 | users = User.find(:all, :limit => 25, :offset => page * 25, :order => :user_name)
24 |
25 | puts "page ##{page}"
26 | puts "---------------"
27 |
28 | if users.empty?
29 | puts "no users found"
30 | else
31 | users.each do |user|
32 | puts "##{user.id}: \"#{user.name}\" <#{user.email}>"
33 | end
34 | end
35 | end
36 |
37 | desc "Report info about particular user (USERNAME env var)."
38 | task :show => :environment do
39 | user = User.find_by_user_name(ENV['USERNAME'])
40 |
41 | if user
42 | puts "##{user.id}: \"#{user.name}\" <#{user.email}>"
43 | else
44 | puts "No user with that user name."
45 | end
46 | end
47 |
48 | desc "List all subscriptions for the given user (USER_ID env var)"
49 | task :subscriptions => :environment do
50 | user = User.find(ENV['USER_ID'])
51 |
52 | if user.subscriptions.empty?
53 | puts "No subscriptions for `#{user.user_name}' ##{user.id}"
54 | else
55 | puts "Subscriptions for `#{user.user_name}' ##{user.id}"
56 | user.subscriptions.each do |sub|
57 | puts "##{sub.id}"
58 | end
59 | end
60 | end
61 |
62 | desc "Grant access to a specific subscription id (USER_ID env var, SUBSCRIPTION_ID env var)."
63 | task :grant => :environment do
64 | subscription = Subscription.find(ENV['SUBSCRIPTION_ID'])
65 | user = User.find(ENV['USER_ID'])
66 | user.subscriptions << subscription
67 | puts "user `#{user.user_name}' granted access to subscription ##{subscription.id}"
68 | end
69 |
70 | desc "Revoke access to a specific subscription id (USER_ID env var, SUBSCRIPTION_ID env var)."
71 | task :revoke => :environment do
72 | subscription = Subscription.find(ENV['SUBSCRIPTION_ID'])
73 | user = User.find(ENV['USER_ID'])
74 | user.subscriptions.delete(subscription)
75 | puts "user `#{user.user_name}' revoked access to subscription ##{subscription.id}"
76 | end
77 | end
78 |
--------------------------------------------------------------------------------
/test/functional/tagged_items_controller_test.rb:
--------------------------------------------------------------------------------
1 | require 'test_helper'
2 |
3 | class TaggedItemsControllerTest < ActionController::TestCase
4 | setup :login_default_user
5 |
6 | # == API tests ========================================================================
7 |
8 | test "create via API for inaccessible event should 404" do
9 | assert_no_difference "TaggedItem.count" do
10 | post :create, :event_id => events(:tim_checking_starting_balance).id,
11 | :tagged_item => { :amount => 100, :tag_id => tags(:john_tip).id },
12 | :format => "xml"
13 | assert_response :missing
14 | end
15 | end
16 |
17 | test "create via API for inaccessible tag should 404" do
18 | assert_no_difference "TaggedItem.count" do
19 | post :create, :event_id => events(:john_lunch).id,
20 | :tagged_item => { :amount => 100, :tag_id => tags(:tim_milk).id },
21 | :format => "xml"
22 | assert_response :missing
23 | end
24 | end
25 |
26 | test "create via API should add tagged item and return 201" do
27 | assert_difference "TaggedItem.count" do
28 | post :create, :event_id => events(:john_lunch).id,
29 | :tagged_item => { :amount => 100, :tag_id => tags(:john_fuel).id },
30 | :format => "xml"
31 | assert_response :success
32 | end
33 |
34 | xml = Hash.from_xml(@response.body)
35 | assert xml.key?("tagged_item")
36 | assert events(:john_lunch, :reload).tagged_items.any? { |i| i.tag == tags(:john_fuel) }
37 | end
38 |
39 | test "create via API should allow tag to be specified by name" do
40 | assert_difference "TaggedItem.count" do
41 | assert_difference "Tag.count" do
42 | post :create, :event_id => events(:john_lunch).id,
43 | :tagged_item => { :amount => 100, :tag_id => "n:misc" },
44 | :format => "xml"
45 | assert_response :success
46 | end
47 | end
48 |
49 | xml = Hash.from_xml(@response.body)
50 | assert xml.key?("tagged_item")
51 | assert events(:john_lunch, :reload).tagged_items.any? { |i| i.tag.name == "misc" }
52 | end
53 |
54 | test "destroy via API for inaccessible tagged item should 404" do
55 | login! :tim
56 | assert_no_difference "TaggedItem.count" do
57 | delete :destroy, :id => tagged_items(:john_lunch_tip).id, :format => "xml"
58 | assert_response :missing
59 | end
60 | end
61 |
62 | test "destroy via API should remove tagged item from event and return 200" do
63 | assert_difference "TaggedItem.count", -1 do
64 | delete :destroy, :id => tagged_items(:john_lunch_tip).id, :format => "xml"
65 | assert_response :success
66 | end
67 | end
68 | end
69 |
--------------------------------------------------------------------------------
/README.rdoc:
--------------------------------------------------------------------------------
1 | = BucketWise
2 |
3 | * http://github.com/jamis/bucketwise
4 |
5 | == DESCRIPTION:
6 |
7 | BucketWise is a web application (written in Ruby on Rails) for managing your personal finances. It features an emphasis on budgeting (using a version of the envelope system) and avoiding debt. BucketWise is highly opinionated: it was written for one man's specific needs, and as an experiment, and while it is working very well for that one man, there is no guarantee it will do what you want. (See FEATURES, below).
8 |
9 | == FEATURES:
10 |
11 | * Multiple users
12 | * Track multiple accounts
13 | * Accounts may be partitioned into "buckets", as a means of budgeting and saving money
14 | * Earmark funds for each credit-card purchase to avoid overspending
15 |
16 | Things it intentionally does *not* do:
17 |
18 | * It does NOT automatically pull your transactions from your bank account, and in all likelihood, never will.
19 | * It does NOT intelligently learn from your purchases and try to guess how to categorize them, and in all likelihood, never will.
20 | * It does NOT provide a social forum for you to share thoughts with other users, and DEFINITELY never will.
21 |
22 | == REQUIREMENTS:
23 |
24 | Essentially, all you need is Ruby, Rake, and version 2.3 of the Ruby on Rails framework. To run locally, it is recommended you also have sqlite3 installed, as well as the sqlite3-ruby bindings. It's also easiest if you have Capistrano 2.5.x installed.
25 |
26 | == INSTALL:
27 |
28 | Installation is about as convenient as it is for any other Ruby on Rails application. It goes something like this (and please excuse the gratuitous hand-waving):
29 |
30 | * Get a server on which to install it
31 | * Install all the software it needs
32 | * Configure the database
33 | * Deploy the application
34 |
35 | To get started quickly, you can just run it locally. Setting that up goes something like this:
36 |
37 | * "cap local externals:setup" to load up the dependencies that BucketWise needs. (You'll need to have git installed for this to work.)
38 | * "rake db:schema:load" to prepare the database
39 | * "rake user:create" to create your first user, and note the user-id
40 | * "rake subscription:create USER_ID=" to create your first "subscription" (a container for your financial account data).
41 | * "script/server", and then go to http://localhost:3000/ to log in and get started!
42 |
43 | If you want to use Capistrano to deploy BucketWise to a server of your own, you can simply create a file at ~/.bucketwise/Capfile, which should contain your custom deployment instructions. If that file exists, BucketWise's own config/deploy.rb will load it automatically.
44 |
45 | == LICENSE:
46 |
47 | This software is hereby placed in the public domain.
48 | - Jamis Buck (author), April 2009
49 |
--------------------------------------------------------------------------------
/app/controllers/buckets_controller.rb:
--------------------------------------------------------------------------------
1 | class BucketsController < ApplicationController
2 | acceptable_includes :author
3 |
4 | before_filter :find_account, :only => %w(index new create)
5 | before_filter :find_bucket, :except => %w(index new create)
6 |
7 | def index
8 | @filter = QueryFilter.new(params)
9 | @buckets = account.buckets.filter(@filter)
10 |
11 | respond_to do |format|
12 | format.html
13 | format.xml { render :xml => @buckets.to_xml(eager_options(:root => "buckets")) }
14 | end
15 | end
16 |
17 | def show
18 | respond_to do |format|
19 | format.html do
20 | @page = (params[:page] || 0).to_i
21 | @more_pages, @items = bucket.line_items.page(@page)
22 | end
23 |
24 | format.xml { render :xml => bucket.to_xml(eager_options) }
25 | end
26 | end
27 |
28 | def new
29 | respond_to { |format| format.xml { render :xml => Bucket.template.to_xml } }
30 | end
31 |
32 | def create
33 | respond_to do |format|
34 | format.xml do
35 | @bucket = account.buckets.create!(params[:bucket], :author => user)
36 | render :status => :created, :xml => @bucket.to_xml, :location => bucket_url(@bucket)
37 | end
38 | end
39 | rescue ActiveRecord::RecordInvalid => error
40 | @bucket = error.record
41 | respond_to do |format|
42 | format.xml { render :status => :unprocessable_entity, :xml => @bucket.errors.to_xml }
43 | end
44 | end
45 |
46 | def update
47 | bucket.update_attributes!(params[:bucket])
48 |
49 | respond_to do |format|
50 | format.js
51 | format.xml { render :xml => bucket.to_xml }
52 | end
53 | rescue ActiveRecord::RecordInvalid
54 | respond_to do |format|
55 | format.js
56 | format.xml { render :status => :unprocessable_entity, :xml => bucket.errors.to_xml }
57 | end
58 | end
59 |
60 | def destroy
61 | receiver = account.buckets.find(params[:receiver_id])
62 | receiver.assimilate(bucket)
63 |
64 | respond_to do |format|
65 | format.html { redirect_to(receiver) }
66 | format.xml { head :ok }
67 | end
68 | end
69 |
70 | protected
71 |
72 | attr_reader :account, :bucket, :buckets, :filter
73 | helper_method :account, :bucket, :buckets, :filter
74 |
75 | def find_account
76 | @account = Account.find(params[:account_id])
77 | @subscription = user.subscriptions.find(@account.subscription_id)
78 | end
79 |
80 | def find_bucket
81 | @bucket = Bucket.find(params[:id])
82 | @account = @bucket.account
83 | @subscription = user.subscriptions.find(@account.subscription_id)
84 | end
85 |
86 | def current_location
87 | if bucket
88 | "buckets/%d" % bucket.id
89 | else
90 | super
91 | end
92 | end
93 | end
94 |
--------------------------------------------------------------------------------
/test/unit/bucket_test.rb:
--------------------------------------------------------------------------------
1 | require 'test_helper'
2 |
3 | class BucketTest < ActiveSupport::TestCase
4 | test "assimilate should change all references of argument to self and destroy argument" do
5 | groceries = buckets(:john_checking_groceries)
6 | dining = buckets(:john_checking_dining)
7 | old_groceries_balance = groceries.balance
8 | old_dining_balance = dining.balance
9 |
10 | groceries.assimilate(dining)
11 |
12 | assert !Bucket.exists?(dining.id)
13 | assert_equal groceries, line_items(:john_lunch_checking_dining).bucket
14 | assert_equal buckets(:john_checking_groceries, :reload).balance, old_groceries_balance + old_dining_balance
15 | end
16 |
17 | test "assimilate bucket from different account should raise exception and make no change" do
18 | groceries = buckets(:john_checking_groceries)
19 | general = buckets(:john_mastercard_general)
20 |
21 | assert_raise ArgumentError do
22 | groceries.assimilate(general)
23 | end
24 |
25 | assert Bucket.exists?(general.id)
26 | assert_equal general, line_items(:john_lunch_mastercard).bucket
27 | end
28 |
29 | test "assimilate self should raise exception and make no change" do
30 | groceries = buckets(:john_checking_groceries)
31 |
32 | assert_no_difference "Bucket.count" do
33 | assert_raise ArgumentError do
34 | groceries.assimilate(groceries)
35 | end
36 | end
37 | end
38 |
39 | test "blank names should be disallowed" do
40 | assert_no_difference "Bucket.count" do
41 | bucket = accounts(:john_checking).buckets.create(
42 | { :name => "", :role => "" },
43 | :author => users(:john))
44 |
45 | assert bucket.errors.on(:name)
46 | end
47 | end
48 |
49 | test "duplicate names are allowed for different accounts" do
50 | assert_difference "Bucket.count" do
51 | bucket = accounts(:john_savings).buckets.create(
52 | { :name => buckets(:john_checking_dining).name, :role => "" },
53 | :author => users(:john))
54 |
55 | assert bucket.errors.on(:name).blank?
56 | end
57 | end
58 |
59 | test "duplicate names are disallowed within the same account" do
60 | assert_no_difference "Bucket.count" do
61 | bucket = accounts(:john_checking).buckets.create(
62 | { :name => buckets(:john_checking_dining).name, :role => "" },
63 | :author => users(:john))
64 |
65 | assert bucket.errors.on(:name)
66 | end
67 | end
68 |
69 | test "balance should read computed_balance if that value is set" do
70 | filter = QueryFilter.new(:expenses => true)
71 | dining = accounts(:john_checking).buckets.filter(filter).find(:first, :conditions => { :name => "Dining" })
72 | assert dining[:computed_balance]
73 | assert_not_equal dining[:balance], dining[:computed_balance]
74 | assert_equal dining[:computed_balance].to_i, dining.balance
75 | end
76 | end
77 |
--------------------------------------------------------------------------------
/test/fixtures/events.yml:
--------------------------------------------------------------------------------
1 | # --------------------------------------------------------------
2 | # john
3 | # --------------------------------------------------------------
4 |
5 | john_checking_starting_balance:
6 | subscription: john
7 | user: john
8 | occurred_on: <%= 60.days.ago.to_date.to_s(:db) %>
9 | actor: john_starting_balance
10 | actor_name: Starting balance
11 | check_number: ~
12 | created_at: <%= (60.days.ago + 1.hour).utc.to_s(:db) %>
13 | updated_at: <%= (60.days.ago + 1.hour).utc.to_s(:db) %>
14 |
15 | john_lunch:
16 | subscription: john
17 | user: john
18 | occurred_on: <%= 59.days.ago.to_date.to_s(:db) %>
19 | actor_name: Sandwich Central
20 | actor: john_sandwich_central
21 | check_number: ~
22 | created_at: <%= 59.days.ago.utc.to_s(:db) %>
23 | updated_at: <%= 59.days.ago.utc.to_s(:db) %>
24 |
25 | john_bill_pay:
26 | subscription: john
27 | user: john
28 | occurred_on: <%= 58.days.ago.to_date.to_s(:db) %>
29 | actor_name: MasterCard Co.
30 | actor: john_mastercard_co
31 | check_number: 1234
32 | created_at: <%= 58.days.ago.utc.to_s(:db) %>
33 | updated_at: <%= 58.days.ago.utc.to_s(:db) %>
34 |
35 | john_reallocate_from:
36 | subscription: john
37 | user: john
38 | occurred_on: <%= 57.days.ago.to_date.to_s(:db) %>
39 | actor_name: Bucket reallocation
40 | actor: john_bucket_reallocation
41 | check_number: ~
42 | created_at: <%= 57.days.ago.utc.to_s(:db) %>
43 | updated_at: <%= 57.days.ago.utc.to_s(:db) %>
44 |
45 | john_reallocate_to:
46 | subscription: john
47 | user: john
48 | occurred_on: <%= 56.days.ago.to_date.to_s(:db) %>
49 | actor_name: Bucket reallocation
50 | actor: john_bucket_reallocation
51 | check_number: ~
52 | created_at: <%= 56.days.ago.utc.to_s(:db) %>
53 | updated_at: <%= 56.days.ago.utc.to_s(:db) %>
54 |
55 | john_lunch_again:
56 | subscription: john
57 | user: john
58 | occurred_on: <%= 55.days.ago.to_date.to_s(:db) %>
59 | actor_name: Sandwich Central
60 | actor: john_sandwich_central
61 | check_number: ~
62 | created_at: <%= 55.days.ago.utc.to_s(:db) %>
63 | updated_at: <%= 55.days.ago.utc.to_s(:db) %>
64 |
65 | john_bare_mastercard:
66 | subscription: john
67 | user: john
68 | occurred_on: <%= 54.days.ago.to_date.to_s(:db) %>
69 | actor_name: Auto fuel
70 | actor: john_auto_fuel
71 | check_number: ~
72 | created_at: <%= 54.days.ago.utc.to_s(:db) %>
73 | updated_at: <%= 54.days.ago.utc.to_s(:db) %>
74 |
75 | # --------------------------------------------------------------
76 | # tim
77 | # --------------------------------------------------------------
78 |
79 | tim_checking_starting_balance:
80 | subscription: tim
81 | user: tim
82 | occurred_on: <%= 59.days.ago.to_date.to_s(:db) %>
83 | actor_name: Starting balance
84 | actor: tim_starting_balance
85 | check_number: ~
86 | created_at: <%= (59.days.ago + 1.hour).utc.to_s(:db) %>
87 | updated_at: <%= (59.days.ago + 1.hour).utc.to_s(:db) %>
88 |
--------------------------------------------------------------------------------
/app/models/statement.rb:
--------------------------------------------------------------------------------
1 | class Statement < ActiveRecord::Base
2 | belongs_to :account
3 | has_many :account_items, :extend => CategorizedItems, :dependent => :nullify
4 |
5 | before_create :initialize_starting_balance
6 | after_save :associate_account_items_with_self
7 |
8 | named_scope :pending, :conditions => { :balanced_at => nil }
9 | named_scope :balanced, :conditions => "balanced_at IS NOT NULL"
10 |
11 | attr_accessible :occurred_on, :ending_balance, :cleared
12 |
13 | validates_presence_of :occurred_on, :ending_balance
14 |
15 | def ending_balance=(amount)
16 | if amount.is_a?(Float)
17 | amount = (amount.to_s.tr(",", "").to_f * 100).round
18 | elsif amount =~ /[.,]/
19 | dollars, cents = amount.split(".")
20 | dollars = dollars.tr(",", "").to_i
21 |
22 | cents = cents[0,2]
23 | cents << "0" while cents.length < 2
24 | cents = cents.to_i
25 |
26 | amount = dollars * 100 + cents
27 | end
28 |
29 | super(amount)
30 | end
31 |
32 | def balance
33 | ending_balance
34 | end
35 |
36 | def balanced?(reload=false)
37 | unsettled_balance(reload).zero?
38 | end
39 |
40 | def settled_balance(reload=false)
41 | @settled_balance = nil if reload
42 | @settled_balance ||= account_items.to_a.sum(&:amount)
43 | end
44 |
45 | def unsettled_balance(reload=false)
46 | @unsettled_balance = nil if reload
47 | @unsettled_balance ||= starting_balance + settled_balance(reload) - ending_balance
48 | end
49 |
50 | def cleared=(ids)
51 | @ids_to_clear = ids
52 | @already_updated = false
53 | end
54 |
55 | protected
56 |
57 | def initialize_starting_balance
58 | self.starting_balance ||= account.statements.balanced.last.try(:ending_balance) || 0
59 | end
60 |
61 | def associate_account_items_with_self
62 | return if @already_updated
63 | @already_updated = true
64 |
65 | account_items.clear
66 |
67 | ids = connection.select_values(sanitize_sql([<<-SQL.squish, account_id, ids_to_clear]))
68 | SELECT ai.id
69 | FROM account_items ai
70 | WHERE ai.account_id = ?
71 | AND ai.id IN (?)
72 | SQL
73 |
74 | connection.update(sanitize_sql([<<-SQL.squish, id, ids]))
75 | UPDATE account_items
76 | SET statement_id = ?
77 | WHERE id IN (?)
78 | SQL
79 |
80 | account_items.reset
81 |
82 | if @ids_to_clear
83 | if balanced?(true) && !balanced_at
84 | update_attribute :balanced_at, Time.now.utc
85 | elsif !balanced? && balanced_at
86 | update_attribute :balanced_at, nil
87 | end
88 | end
89 | end
90 |
91 | private
92 |
93 | def sanitize_sql(sql)
94 | self.class.send(:sanitize_sql, sql)
95 | end
96 |
97 | def ids_to_clear
98 | @ids_to_clear || []
99 | end
100 |
101 | end
102 |
--------------------------------------------------------------------------------
/app/models/bucket.rb:
--------------------------------------------------------------------------------
1 | class Bucket < ActiveRecord::Base
2 | RECENT_WINDOW_SIZE = 10
3 |
4 | Temp = Struct.new(:id, :name, :role, :balance)
5 |
6 | belongs_to :account
7 | belongs_to :author, :class_name => "User", :foreign_key => "user_id"
8 |
9 | has_many :line_items
10 |
11 | attr_accessible :name, :role
12 |
13 | validates_presence_of :name
14 | validates_uniqueness_of :name, :scope => :account_id, :case_sensitive => false
15 |
16 | named_scope :filter, lambda { |filter| Bucket.options_for_filter(filter) }
17 |
18 | def self.options_for_filter(filter)
19 | return {} unless filter.any?
20 |
21 | conditions = []
22 | parameters = []
23 |
24 | if filter.from?
25 | conditions << "line_items.occurred_on >= ?"
26 | parameters << filter.from
27 | end
28 |
29 | if filter.to?
30 | conditions << "line_items.occurred_on <= ?"
31 | parameters << filter.to
32 | end
33 |
34 | if filter.by_type?
35 | roles = []
36 |
37 | if filter.expenses?
38 | roles << 'payment_source'
39 | roles << 'transfer_from'
40 | roles << 'credit_options'
41 | end
42 |
43 | if filter.deposits?
44 | roles << 'deposit'
45 | roles << 'transfer_to'
46 | end
47 |
48 | if filter.reallocations?
49 | roles << 'primary'
50 | roles << 'aside'
51 | roles << 'credit_options'
52 | roles << 'reallocation_from'
53 | roles << 'reallocation_to'
54 | end
55 |
56 | conditions << "line_items.role IN (?)"
57 | parameters << roles.uniq
58 | end
59 |
60 | { :joins => "LEFT OUTER JOIN line_items ON line_items.bucket_id = buckets.id",
61 | :select => "buckets.*, SUM(line_items.amount) as computed_balance",
62 | :conditions => [conditions.join(" AND "), *parameters],
63 | :group => "buckets.id" }
64 | end
65 |
66 | def self.default
67 | Temp.new("r:default", "General", "default", 0)
68 | end
69 |
70 | def self.aside
71 | Temp.new("r:aside", "Aside", "aside", 0)
72 | end
73 |
74 | def self.template
75 | new :name => "Bucket name (e.g. Groceries)",
76 | :role => "aside | default | nil"
77 | end
78 |
79 | def self.recent(n=RECENT_WINDOW_SIZE)
80 | find(:all, :limit => n, :order => "updated_at DESC").sort_by(&:name)
81 | end
82 |
83 | def balance
84 | (self[:computed_balance] || self[:balance]).to_i
85 | end
86 |
87 | def assimilate(bucket)
88 | if bucket == self
89 | raise ArgumentError, "cannot assimilate self"
90 | end
91 |
92 | if bucket.account_id != account_id
93 | raise ArgumentError, "cannot assimilate bucket from different account"
94 | end
95 |
96 | old_id = bucket.id
97 |
98 | Bucket.transaction do
99 | LineItem.update_all(["bucket_id = ?", id], :bucket_id => old_id)
100 | update_attribute :balance, balance + bucket.balance
101 | bucket.destroy
102 | end
103 | end
104 |
105 | def to_xml(options={})
106 | options[:only] = %w(name role) if new_record?
107 | super(options)
108 | end
109 | end
110 |
--------------------------------------------------------------------------------
/config/boot.rb:
--------------------------------------------------------------------------------
1 | # Don't change this file!
2 | # Configure your app in config/environment.rb and config/environments/*.rb
3 |
4 | RAILS_ROOT = "#{File.dirname(__FILE__)}/.." unless defined?(RAILS_ROOT)
5 |
6 | module Rails
7 | class << self
8 | def boot!
9 | unless booted?
10 | preinitialize
11 | pick_boot.run
12 | end
13 | end
14 |
15 | def booted?
16 | defined? Rails::Initializer
17 | end
18 |
19 | def pick_boot
20 | (vendor_rails? ? VendorBoot : GemBoot).new
21 | end
22 |
23 | def vendor_rails?
24 | File.exist?("#{RAILS_ROOT}/vendor/rails")
25 | end
26 |
27 | def preinitialize
28 | load(preinitializer_path) if File.exist?(preinitializer_path)
29 | end
30 |
31 | def preinitializer_path
32 | "#{RAILS_ROOT}/config/preinitializer.rb"
33 | end
34 | end
35 |
36 | class Boot
37 | def run
38 | load_initializer
39 | Rails::Initializer.run(:set_load_path)
40 | end
41 | end
42 |
43 | class VendorBoot < Boot
44 | def load_initializer
45 | require "#{RAILS_ROOT}/vendor/rails/railties/lib/initializer"
46 | Rails::Initializer.run(:install_gem_spec_stubs)
47 | end
48 | end
49 |
50 | class GemBoot < Boot
51 | def load_initializer
52 | self.class.load_rubygems
53 | load_rails_gem
54 | require 'initializer'
55 | end
56 |
57 | def load_rails_gem
58 | if version = self.class.gem_version
59 | gem 'rails', version
60 | else
61 | gem 'rails'
62 | end
63 | rescue Gem::LoadError => load_error
64 | $stderr.puts %(Missing the Rails #{version} gem. Please `gem install -v=#{version} rails`, update your RAILS_GEM_VERSION setting in config/environment.rb for the Rails version you do have installed, or comment out RAILS_GEM_VERSION to use the latest version installed.)
65 | exit 1
66 | end
67 |
68 | class << self
69 | def rubygems_version
70 | Gem::RubyGemsVersion if defined? Gem::RubyGemsVersion
71 | end
72 |
73 | def gem_version
74 | if defined? RAILS_GEM_VERSION
75 | RAILS_GEM_VERSION
76 | elsif ENV.include?('RAILS_GEM_VERSION')
77 | ENV['RAILS_GEM_VERSION']
78 | else
79 | parse_gem_version(read_environment_rb)
80 | end
81 | end
82 |
83 | def load_rubygems
84 | require 'rubygems'
85 |
86 | unless rubygems_version >= '0.9.4'
87 | $stderr.puts %(Rails requires RubyGems >= 0.9.4 (you have #{rubygems_version}). Please `gem update --system` and try again.)
88 | exit 1
89 | end
90 |
91 | rescue LoadError
92 | $stderr.puts %(Rails requires RubyGems >= 0.9.4. Please install RubyGems and try again: http://rubygems.rubyforge.org)
93 | exit 1
94 | end
95 |
96 | def parse_gem_version(text)
97 | $1 if text =~ /^[^#]*RAILS_GEM_VERSION\s*=\s*["']([!~<>=]*\s*[\d.]+)["']/
98 | end
99 |
100 | private
101 | def read_environment_rb
102 | File.read("#{RAILS_ROOT}/config/environment.rb")
103 | end
104 | end
105 | end
106 | end
107 |
108 | # All that for this:
109 | Rails.boot!
110 |
--------------------------------------------------------------------------------
/public/javascripts/accounts.js:
--------------------------------------------------------------------------------
1 | var Accounts = {
2 | revealForm: function() {
3 | if($('blankslate')) {
4 | $('blankslate').hide();
5 | } else {
6 | $('data').hide();
7 | $('links').hide();
8 | }
9 |
10 | $('new_account').show();
11 | $('account_name').activate();
12 | },
13 |
14 | hideForm: function() {
15 | if(Accounts.origin) {
16 | window.location = Accounts.origin;
17 | } else {
18 | Accounts.reset();
19 | $('new_account').hide();
20 |
21 | if($('blankslate')) {
22 | $('blankslate').show();
23 | } else {
24 | $('data').show();
25 | $('links').show();
26 | }
27 | }
28 | },
29 |
30 | submit: function() {
31 | if($F('account_name').blank()) {
32 | $('account_name').activate();
33 | alert('Please provide a name for the account.');
34 | return false;
35 | }
36 |
37 | if($F('account_role') == 'credit-card' && $F('account_limit').blank()) {
38 | $('account_name').activate();
39 | alert('Please provide a limit for the account.');
40 | return false;
41 | }
42 |
43 | var balance = Money.parse('current_balance', true);
44 | $('account_starting_balance_amount').value = balance;
45 |
46 | var limit = Money.parse('account_limit', true);
47 | $('account_limit').value = limit;
48 |
49 | return true;
50 | },
51 |
52 | reset: function() {
53 | $('new_account_form').reset();
54 | },
55 |
56 | rename: function(url, name, token) {
57 | new_name = prompt("Enter the name for this account:", name);
58 | if(new_name && new_name != name) {
59 | params = encodeURIComponent("account[name]") + "=" + encodeURIComponent(new_name) +
60 | "&authenticity_token=" + encodeURIComponent(token);
61 |
62 | new Ajax.Request(url, {
63 | asynchronous:true,
64 | evalScripts:true,
65 | method:'put',
66 | parameters:params
67 | });
68 | }
69 | },
70 |
71 | adjustLimit: function(url, limit, token) {
72 | new_limit = prompt("Enter the new limit for this account:", Money.formatValue(limit));
73 | new_limit = Money.parseValue(new_limit);
74 | while(new_limit == '') {
75 | new_limit = prompt("Cannot have have a blank limit. Please re-enter it:", Money.formatValue(limit));
76 | }
77 | if(new_limit && new_limit != limit) {
78 | params = encodeURIComponent("account[limit]") + "=" + encodeURIComponent(new_limit) +
79 | "&authenticity_token=" + encodeURIComponent(token);
80 |
81 | new Ajax.Request(url, {
82 | asynchronous:true,
83 | evalScripts:true,
84 | method:'put',
85 | parameters:params,
86 | onSuccess: function(request) {
87 | window.location.reload();
88 | }
89 | });
90 | }
91 | },
92 |
93 | showOrHideCreditLimit: function(value) {
94 | if (value == 'credit-card') {
95 | Accounts.showCreditLimit();
96 | } else {
97 | Accounts.hideCreditLimit();
98 | }
99 | },
100 |
101 | showCreditLimit: function() {
102 | $('credit_limit_div').show();
103 | },
104 |
105 | hideCreditLimit: function() {
106 | $('credit_limit_div').hide();
107 | }
108 | }
109 |
--------------------------------------------------------------------------------
/app/models/subscription.rb:
--------------------------------------------------------------------------------
1 | class Subscription < ActiveRecord::Base
2 | DEFAULT_PAGE_SIZE = 5
3 |
4 | belongs_to :owner, :class_name => "User"
5 |
6 | has_many :accounts
7 | has_many :tags
8 | has_many :actors
9 |
10 | has_many :events do
11 | def recent(n=0, options={})
12 | size = (options[:size] || DEFAULT_PAGE_SIZE).to_i
13 | n = n.to_i
14 |
15 | joins = []
16 | conditions = []
17 | parameters = []
18 |
19 | if options[:actor]
20 | joins << "LEFT JOIN actors ON actors.id = events.actor_id"
21 | conditions << "actors.sort_name = ?"
22 | parameters << Actor.normalize_name(options[:actor])
23 | end
24 |
25 | records = find(:all, :joins => joins,
26 | :conditions => conditions.any? ? [conditions.join(" AND "), *parameters] : nil,
27 | :include => :account_items,
28 | :order => "events.created_at DESC",
29 | :limit => size + 1,
30 | :offset => n * size)
31 |
32 | [records.length > size, records[0,size]]
33 | end
34 |
35 | def prepare(attrs={})
36 | event = build(:role => attrs[:role], :occurred_on => Date.today)
37 |
38 | case event.role
39 | when :reallocation
40 | event.actor_name = "Bucket reallocation"
41 | if attrs[:from]
42 | bucket = Bucket.find(attrs[:from])
43 | account = @owner.accounts.find(bucket.account_id)
44 | event.line_items.build(:role => "primary", :account => account, :bucket => bucket)
45 | event.line_items.build(:role => "reallocate_from", :account => account, :bucket => account.buckets.default)
46 | elsif attrs[:to]
47 | bucket = Bucket.find(attrs[:to])
48 | account = @owner.accounts.find(bucket.account_id)
49 | event.line_items.build(:role => "primary", :account => account, :bucket => bucket)
50 | event.line_items.build(:role => "reallocate_to", :account => account, :bucket => account.buckets.default)
51 | end
52 | end
53 |
54 | return event
55 | end
56 | end
57 |
58 | has_many :user_subscriptions
59 | has_many :users, :through => :user_subscriptions
60 |
61 | # removes everything from the subscription, without deleting the subscription
62 | def clean
63 | transaction do
64 | connection.delete "DELETE FROM line_items WHERE event_id IN (SELECT id FROM events WHERE subscription_id = #{id})"
65 | connection.delete "DELETE FROM account_items WHERE event_id IN (SELECT id FROM events WHERE subscription_id = #{id})"
66 | connection.delete "DELETE FROM tagged_items WHERE event_id IN (SELECT id FROM events WHERE subscription_id = #{id})"
67 |
68 | connection.delete "DELETE FROM buckets WHERE account_id IN (SELECT id FROM accounts WHERE subscription_id = #{id})"
69 | connection.delete "DELETE FROM statements WHERE account_id IN (SELECT id FROM accounts WHERE subscription_id = #{id})"
70 |
71 | connection.delete "DELETE FROM actors WHERE subscription_id = #{id}"
72 | connection.delete "DELETE FROM events WHERE subscription_id = #{id}"
73 | connection.delete "DELETE FROM accounts WHERE subscription_id = #{id}"
74 | connection.delete "DELETE FROM tags WHERE subscription_id = #{id}"
75 | end
76 | end
77 |
78 | # an optimized destroy to avoid costly dependency cascades
79 | def destroy
80 | transaction do
81 | clean
82 | connection.delete "DELETE FROM user_subscriptions WHERE subscription_id = #{id}"
83 | connection.delete "DELETE FROM subscriptions WHERE id = #{id}"
84 | end
85 | end
86 | end
87 |
--------------------------------------------------------------------------------
/test/unit/statement_test.rb:
--------------------------------------------------------------------------------
1 | require 'test_helper'
2 |
3 | class StatementTest < ActiveSupport::TestCase
4 | test "creation with ending balance as dollars should be translated to cents" do
5 | statement = accounts(:john_checking).statements.create(:occurred_on => Date.today,
6 | :ending_balance => "1,234.56")
7 | assert_equal 1_234_56, statement.ending_balance
8 | end
9 |
10 | test "creation with cleared ids should set statement id for given account items" do
11 | items = [:john_checking_starting_balance, :john_bill_pay_checking].map { |i| account_items(i).id }
12 |
13 | statement = accounts(:john_checking).statements.create(
14 | :occurred_on => Date.today, :ending_balance => 1_234_56, :cleared => items)
15 |
16 | assert_equal items, statement.account_items.map(&:id)
17 | end
18 |
19 | test "creation with cleared ids should filter out items for different accounts" do
20 | items = [:john_checking_starting_balance, :john_bill_pay_checking].map { |i| account_items(i).id }
21 | bad_items = items + [account_items(:john_lunch_mastercard).id]
22 |
23 | statement = accounts(:john_checking).statements.create(
24 | :occurred_on => Date.today, :ending_balance => 1_234_56, :cleared => bad_items)
25 |
26 | assert_equal items, statement.account_items.map(&:id)
27 | end
28 |
29 | test "balanced_at should not be set when no items have been given" do
30 | statement = accounts(:john_checking).statements.create(:occurred_on => Date.today,
31 | :ending_balance => 1_234_56)
32 | assert_nil statement.balanced_at
33 | end
34 |
35 | test "balanced_at should be set automatically when items all balance" do
36 | statements(:john).destroy # get this one out of the way
37 |
38 | items = [:john_checking_starting_balance, :john_bill_pay_checking].map { |i| account_items(i).id }
39 |
40 | statement = accounts(:john_checking).statements.create(
41 | :occurred_on => Date.today, :ending_balance => 992_25, :cleared => items)
42 |
43 | assert_not_nil statement.balanced_at
44 | assert (Time.now - statement.balanced_at) < 1
45 | end
46 |
47 | test "balanced_at should be cleared automatically when items do not balance" do
48 | items = [:john_checking_starting_balance, :john_bill_pay_checking].map { |i| account_items(i).id }
49 |
50 | statement = accounts(:john_checking).statements.create(:occurred_on => Date.today,
51 | :ending_balance => 1_234_56)
52 | statement.balanced_at = Time.now.utc
53 | statement.save
54 |
55 | assert_not_nil statement.reload.balanced_at
56 |
57 | statement.update_attributes :cleared => items
58 | assert_nil statement.reload.balanced_at
59 | end
60 |
61 | test "deleting statement should nullify association with account items" do
62 | assert statements(:john).account_items.any?
63 | assert_equal statements(:john), account_items(:john_checking_starting_balance).statement
64 |
65 | assert_difference "Statement.count", -1 do
66 | assert_no_difference "AccountItem.count" do
67 | statements(:john).destroy
68 | end
69 | end
70 |
71 | assert_nil account_items(:john_checking_starting_balance, :reload).statement
72 | end
73 |
74 | test "balanced should be true when unsettled balance is zero" do
75 | assert statements(:john).balanced?
76 | statements(:john).update_attributes :ending_balance => 1234_56,
77 | :cleared => statements(:john).account_items.map(&:id)
78 | assert !statements(:john).balanced?(true)
79 | end
80 |
81 | test "ending_balance should not have truncation errors" do
82 | statements(:john).update_attribute :ending_balance, 2557.68
83 | assert statements(:john).ending_balance == 255768
84 | end
85 | end
86 |
--------------------------------------------------------------------------------
/test/fixtures/line_items.yml:
--------------------------------------------------------------------------------
1 | # --------------------------------------------------------------
2 | # john
3 | # --------------------------------------------------------------
4 |
5 | john_checking_starting_balance:
6 | event: john_checking_starting_balance
7 | account: john_checking
8 | bucket: john_checking_general
9 | amount: 100000
10 | role: deposit
11 | occurred_on: <%= 60.days.ago.to_date.to_s(:db) %>
12 |
13 | john_lunch_mastercard:
14 | event: john_lunch
15 | account: john_mastercard
16 | bucket: john_mastercard_general
17 | amount: -775
18 | role: payment_source
19 | occurred_on: <%= 59.days.ago.utc.to_s(:db) %>
20 |
21 | john_lunch_checking_dining:
22 | event: john_lunch
23 | account: john_checking
24 | bucket: john_checking_dining
25 | amount: -775
26 | role: credit_options
27 | occurred_on: <%= 59.days.ago.utc.to_s(:db) %>
28 |
29 | john_lunch_checking_aside:
30 | event: john_lunch
31 | account: john_checking
32 | bucket: john_checking_aside
33 | amount: 775
34 | role: aside
35 | occurred_on: <%= 59.days.ago.utc.to_s(:db) %>
36 |
37 | john_bill_pay_checking_aside:
38 | event: john_bill_pay
39 | account: john_checking
40 | bucket: john_checking_aside
41 | amount: -775
42 | role: transfer_from
43 | occurred_on: <%= 58.days.ago.utc.to_s(:db) %>
44 |
45 | john_bill_pay_mastercard_general:
46 | event: john_bill_pay
47 | account: john_mastercard
48 | bucket: john_mastercard_general
49 | amount: 775
50 | role: transfer_to
51 | occurred_on: <%= 58.days.ago.utc.to_s(:db) %>
52 |
53 | john_reallocate_from_general:
54 | event: john_reallocate_from
55 | account: john_checking
56 | bucket: john_general
57 | amount: -1000
58 | role: primary
59 | occurred_on: <%= 57.days.ago.utc.to_s(:db) %>
60 |
61 | john_reallocate_from_dining:
62 | event: john_reallocate_from
63 | account: john_checking
64 | bucket: john_checking_dining
65 | amount: 1000
66 | role: reallocate_from
67 | occurred_on: <%= 57.days.ago.utc.to_s(:db) %>
68 |
69 | john_reallocate_to_general:
70 | event: john_reallocate_to
71 | account: john_checking
72 | bucket: john_general
73 | amount: -1000
74 | role: reallocate_to
75 | occurred_on: <%= 56.days.ago.utc.to_s(:db) %>
76 |
77 | john_reallocate_to_dining:
78 | event: john_reallocate_to
79 | account: john_checking
80 | bucket: john_checking_dining
81 | amount: 1000
82 | role: primary
83 | occurred_on: <%= 56.days.ago.utc.to_s(:db) %>
84 |
85 | john_lunch_again_mastercard:
86 | event: john_lunch_again
87 | account: john_mastercard
88 | bucket: john_mastercard_general
89 | amount: -1025
90 | role: payment_source
91 | occurred_on: <%= 59.days.ago.utc.to_s(:db) %>
92 |
93 | john_lunch_again_checking_dining:
94 | event: john_lunch_again
95 | account: john_checking
96 | bucket: john_checking_dining
97 | amount: -1025
98 | role: credit_options
99 | occurred_on: <%= 59.days.ago.utc.to_s(:db) %>
100 |
101 | john_lunch_again_checking_aside:
102 | event: john_lunch_again
103 | account: john_checking
104 | bucket: john_checking_aside
105 | amount: 1025
106 | role: aside
107 | occurred_on: <%= 59.days.ago.utc.to_s(:db) %>
108 |
109 | john_bare_mastercard_general:
110 | event: john_bare_mastercard
111 | account: john_mastercard
112 | bucket: john_mastercard_general
113 | amount: -1500
114 | role: payment_source
115 | occurred_on: <%= 54.days.ago.utc.to_s(:db) %>
116 |
117 | # --------------------------------------------------------------
118 | # tim
119 | # --------------------------------------------------------------
120 |
121 | tim_checking_starting_balance:
122 | event: tim_checking_starting_balance
123 | account: tim_checking
124 | bucket: tim_checking_general
125 | amount: 125000
126 | role: deposit
127 | occurred_on: <%= 59.days.ago.to_date.to_s(:db) %>
128 |
129 |
--------------------------------------------------------------------------------
/db/migrate/20080513032848_initial_schema.rb:
--------------------------------------------------------------------------------
1 | class InitialSchema < ActiveRecord::Migration
2 | def self.up
3 | create_table :subscriptions do |t|
4 | t.integer :owner_id, :null => false
5 | end
6 |
7 | add_index :subscriptions, :owner_id
8 |
9 | create_table :users do |t|
10 | t.string :name, :null => false
11 | t.string :email, :null => false
12 | t.string :user_name
13 | t.string :password_hash
14 | t.string :salt
15 | t.timestamps
16 | end
17 |
18 | add_index :users, :user_name, :unique => true
19 |
20 | create_table :user_subscriptions do |t|
21 | t.integer :subscription_id, :null => false
22 | t.integer :user_id, :null => false
23 | t.datetime :created_at, :null => false
24 | end
25 |
26 | add_index :user_subscriptions, %w(subscription_id user_id), :unique => true
27 | add_index :user_subscriptions, :user_id
28 |
29 | create_table :accounts do |t|
30 | t.integer :subscription_id, :null => false
31 | t.integer :user_id, :null => false
32 | t.string :name, :null => false
33 | t.string :role
34 | t.timestamps
35 | end
36 |
37 | add_index :accounts, %w(subscription_id name), :unique => true
38 |
39 | create_table :buckets do |t|
40 | t.integer :account_id, :null => false
41 | t.integer :user_id, :null => false
42 | t.string :name, :null => false
43 | t.string :role
44 | t.timestamps
45 | end
46 |
47 | add_index :buckets, %w(account_id name), :unique => true
48 | add_index :buckets, %w(account_id updated_at)
49 |
50 | create_table :events do |t|
51 | t.integer :subscription_id, :null => false
52 | t.integer :user_id, :null => false
53 | t.date :occurred_on, :null => false
54 | t.string :actor, :null => false
55 | t.integer :check_number
56 | t.timestamps
57 | end
58 |
59 | add_index :events, %w(subscription_id occurred_on)
60 | add_index :events, %w(subscription_id actor)
61 | add_index :events, %w(subscription_id check_number)
62 | add_index :events, %w(subscription_id created_at)
63 |
64 | create_table :line_items do |t|
65 | t.integer :event_id, :null => false
66 | t.integer :account_id, :null => false
67 | t.integer :bucket_id, :null => false
68 | t.integer :amount, :null => false # cents
69 | t.string :role, :limit => 20
70 | t.date :occurred_on, :null => false
71 | end
72 |
73 | add_index :line_items, :event_id
74 | add_index :line_items, :account_id
75 | add_index :line_items, %w(bucket_id occurred_on)
76 |
77 | create_table :account_items do |t|
78 | t.integer :event_id, :null => false
79 | t.integer :account_id, :null => false
80 | t.integer :amount, :null => false # cents
81 | t.date :occurred_on, :null => false
82 | end
83 |
84 | add_index :account_items, :event_id
85 | add_index :account_items, %w(account_id occurred_on)
86 |
87 | create_table :tags do |t|
88 | t.integer :subscription_id, :null => false
89 | t.string :name, :null => false
90 | t.integer :balance, :null => false, :default => 0
91 | t.timestamps
92 | end
93 |
94 | add_index :tags, %w(subscription_id name), :unique => true
95 | add_index :tags, %w(subscription_id balance)
96 |
97 | create_table :tagged_items do |t|
98 | t.integer :event_id, :null => false
99 | t.integer :tag_id, :null => false
100 | t.date :occurred_on, :null => false
101 | t.integer :amount, :null => false
102 | end
103 |
104 | add_index :tagged_items, :event_id
105 | add_index :tagged_items, %w(tag_id occurred_on)
106 | end
107 |
108 | def self.down
109 | drop_table :subscriptions
110 | drop_table :users
111 | drop_table :subscribed_users
112 | drop_table :accounts
113 | drop_table :buckets
114 | drop_table :events
115 | drop_table :line_items
116 | drop_table :account_items
117 | drop_table :tags
118 | drop_table :tagged_items
119 | end
120 | end
121 |
--------------------------------------------------------------------------------
/test/functional/statements_controller_test.rb:
--------------------------------------------------------------------------------
1 | require 'test_helper'
2 |
3 | class StatementsControllerTest < ActionController::TestCase
4 | setup :login_default_user
5 |
6 | test "index for inaccessible account should 404" do
7 | get :index, :account_id => accounts(:tim_checking).id
8 | assert_response :missing
9 | end
10 |
11 | test "index should list only balanced statements" do
12 | get :index, :account_id => accounts(:john_checking).id
13 | assert_response :success
14 | assert_template "statements/index"
15 | assert_equal [statements(:john)], assigns(:statements)
16 | end
17 |
18 | test "new for inaccessible account should 404" do
19 | get :new, :account_id => accounts(:tim_checking).id
20 | assert_response :missing
21 | end
22 |
23 | test "new should build template record and render" do
24 | get :new, :account_id => accounts(:john_checking).id
25 | assert_response :success
26 | assert_template "statements/new"
27 | assert assigns(:statement).new_record?
28 | end
29 |
30 | test "create for inaccessible account should 404" do
31 | assert_no_difference "Statement.count" do
32 | post :create, :account_id => accounts(:tim_checking).id,
33 | :statement => { :occurred_on => Date.today, :ending_balance => 1234_56 }
34 | assert_response :missing
35 | end
36 | end
37 |
38 | test "create should create new record and redirect to edit" do
39 | assert_difference "accounts(:john_checking, :reload).statements.size" do
40 | post :create, :account_id => accounts(:john_checking).id,
41 | :statement => { :occurred_on => Date.today, :ending_balance => 1234_56 }
42 | assert_redirected_to edit_statement_url(assigns(:statement))
43 | end
44 | end
45 |
46 | test "show for inaccessible statement should 404" do
47 | get :show, :id => statements(:tim).id
48 | assert_response :missing
49 | end
50 |
51 | test "show should load statement record and render" do
52 | get :show, :id => statements(:john).id
53 | assert_response :success
54 | assert_template "statements/show"
55 | assert_equal statements(:john), assigns(:statement)
56 | end
57 |
58 | test "edit for inaccessible statement should 404" do
59 | get :edit, :id => statements(:tim).id
60 | assert_response :missing
61 | end
62 |
63 | test "edit should load statement record and render" do
64 | get :edit, :id => statements(:john).id
65 | assert_response :success
66 | assert_template "statements/edit"
67 | assert_equal statements(:john), assigns(:statement)
68 | end
69 |
70 | test "update for inaccessible statement should 404" do
71 | put :update, :id => statements(:tim).id,
72 | :statement => { :occurred_on => statements(:tim).occurred_on,
73 | :ending_balance => statements(:tim).ending_balance,
74 | :cleared => [account_items(:tim_checking_starting_balance).id] }
75 | assert_response :missing
76 | assert statements(:tim, :reload).account_items.empty?
77 | end
78 |
79 | test "update should load statement and update statement and redirect" do
80 | assert statements(:john_pending).account_items.empty?
81 | put :update, :id => statements(:john_pending).id,
82 | :statement => { :occurred_on => statements(:john_pending).occurred_on,
83 | :ending_balance => statements(:john_pending).ending_balance,
84 | :cleared => [account_items(:john_lunch_again_checking).id] }
85 | assert_redirected_to account_url(statements(:john_pending).account)
86 | assert_equal [account_items(:john_lunch_again_checking)],
87 | statements(:john_pending, :reload).account_items
88 | end
89 |
90 | test "destroy for inaccessible statement should 404" do
91 | assert_no_difference "Statement.count" do
92 | delete :destroy, :id => statements(:tim).id
93 | assert_response :missing
94 | end
95 | end
96 |
97 | test "destroy should load and destroy statement and redirect" do
98 | delete :destroy, :id => statements(:john).id
99 | assert_redirected_to account_url(statements(:john).account)
100 | assert !Statement.exists?(statements(:john).id)
101 | end
102 | end
103 |
--------------------------------------------------------------------------------
/app/controllers/events_controller.rb:
--------------------------------------------------------------------------------
1 | class EventsController < ApplicationController
2 | acceptable_includes :line_items, :user, :tagged_items
3 |
4 | before_filter :find_container, :find_events, :only => :index
5 | before_filter :find_subscription, :only => %w(new create)
6 | before_filter :find_event, :except => %w(index new create)
7 |
8 | def index
9 | respond_to do |format|
10 | format.js do
11 | json = events.to_json(eager_options(:root => "events", :include => { :tagged_items => { :only => [:amount, :id], :methods => :name }, :line_items => { :only => [:account_id, :bucket_id, :amount, :role], :methods => [] }}))
12 |
13 | render :update do |page|
14 | page << "Events.doneLoadingRecalledEvents(#{json})"
15 | end
16 | end
17 | format.xml do
18 | render :xml => events.to_xml(eager_options(:root => "events"))
19 | end
20 | end
21 | end
22 |
23 | def show
24 | respond_to do |format|
25 | format.js
26 | format.xml { render :xml => event.to_xml(eager_options) }
27 | end
28 | end
29 |
30 | def edit
31 | end
32 |
33 | def new
34 | @event = subscription.events.prepare(params)
35 |
36 | respond_to do |format|
37 | format.html
38 | format.xml { render :xml => event.to_xml(:include => [:line_items, :tagged_items]) }
39 | end
40 | end
41 |
42 | def create
43 | @event = subscription.events.create!(params[:event], :user => user)
44 | respond_to do |format|
45 | format.js
46 | format.xml do
47 | render :status => :created, :location => event_url(@event),
48 | :xml => @event.to_xml(:include => [:line_items, :tagged_items])
49 | end
50 | end
51 | rescue ActiveRecord::RecordInvalid => error
52 | @event = error.record
53 | respond_to do |format|
54 | format.js
55 | format.xml { render :status => :unprocessable_entity, :xml => @event.errors }
56 | end
57 | end
58 |
59 | def update
60 | event.update_attributes!(params[:event])
61 | respond_to do |format|
62 | format.js
63 | format.xml { render :xml => event.to_xml(:include => [:line_items, :tagged_items]) }
64 | end
65 | rescue ActiveRecord::RecordInvalid => error
66 | respond_to do |format|
67 | format.js
68 | format.xml { render :status => :unprocessable_entity, :xml => event.errors }
69 | end
70 | end
71 |
72 | def destroy
73 | event.destroy
74 | respond_to do |format|
75 | format.js
76 | format.xml { head :ok }
77 | end
78 | end
79 |
80 | protected
81 |
82 | attr_reader :event, :container, :account, :bucket, :tag, :events
83 | helper_method :event, :container, :account, :bucket
84 |
85 | def find_event
86 | @event = Event.find(params[:id])
87 | @subscription = user.subscriptions.find(@event.subscription_id)
88 | end
89 |
90 | def find_container
91 | if params[:subscription_id]
92 | @container = find_subscription
93 | elsif params[:account_id]
94 | @container = @account = Account.find(params[:account_id])
95 | @subscription = user.subscriptions.find(@account.subscription_id)
96 | elsif params[:bucket_id]
97 | @container = @bucket = Bucket.find(params[:bucket_id])
98 | @subscription = user.subscriptions.find(@bucket.account.subscription_id)
99 | elsif params[:tag_id]
100 | @container = @tag = Tag.find(params[:tag_id])
101 | @subscription = user.subscriptions.find(@tag.subscription_id)
102 | else
103 | raise ArgumentError, "no container specified for event listing"
104 | end
105 | end
106 |
107 | def find_events
108 | method = :page
109 |
110 | case container
111 | when Subscription then
112 | association = :events
113 | method = :recent
114 | when Account
115 | association = :account_items
116 | when Bucket
117 | association = :line_items
118 | when Tag
119 | association = :tagged_items
120 | else
121 | raise ArgumentError, "unsupported container type #{container.class}"
122 | end
123 |
124 | more_pages, list = container.send(association).send(method, params[:page], :size => params[:size], :actor => params[:actor])
125 | unless list.first.is_a?(Event)
126 | list = list.map do |item|
127 | event = item.event
128 | event.amount = item.amount
129 | event
130 | end
131 | end
132 |
133 | @events = list
134 | end
135 | end
136 |
--------------------------------------------------------------------------------
/lib/populator.rb:
--------------------------------------------------------------------------------
1 | class Populator
2 | def self.for(subscription)
3 | populator = new(subscription)
4 | yield populator
5 | populator.commit!
6 | end
7 |
8 | attr_reader :subscription
9 |
10 | def initialize(subscription)
11 | @subscription = subscription
12 | @accounts = []
13 | @posts = []
14 | end
15 |
16 | def account(name, role, starting_balance, balance_date, limit=nil)
17 | raise "you have to specify a limit for credit-card accounts" if role == "credit-card" && limit.nil?
18 | @accounts << { :name => name, :role => role, :limit => limit,
19 | :starting_balance => { :amount => starting_balance, :occurred_on => balance_date }}
20 | end
21 |
22 | def post(occurred_on, actor_name, default_amount=nil)
23 | returning Post.new(occurred_on, actor_name, default_amount) do |post|
24 | @posts << post
25 | end
26 | end
27 |
28 | def commit!
29 | Account.transaction do
30 | @accounts.each do |account|
31 | subscription.accounts.create!(account, :author => subscription.users.rand)
32 | end
33 |
34 | acct_cache = Hash.new { |h,k| h[k] = subscription.accounts.find_by_name(k) or raise IndexError, "key not found: #{k}" }
35 |
36 | @posts.each do |post|
37 | post.line_items.each do |item|
38 | if acct_name = item.delete(:account)
39 | acct = acct_cache[acct_name]
40 | item[:account_id] = acct.id
41 | end
42 |
43 | if bucket_name = item.delete(:bucket)
44 | if bucket_name =~ /^[rn]:/
45 | item[:bucket_id] = bucket_name
46 | else
47 | item[:bucket_id] = "n:#{bucket_name}"
48 | end
49 | end
50 | end
51 |
52 | event_data = { :occurred_on => post.occurred_on, :actor_name => post.actor_name,
53 | :check_number => post.check_number, :memo => post.memo,
54 | :line_items => post.line_items, :tagged_items => post.tagged_items }
55 |
56 | subscription.events.create!(event_data, :user => subscription.users.rand)
57 | end
58 | end
59 | end
60 |
61 | class Post
62 | attr_reader :occurred_on, :actor_name, :check_number
63 | attr_reader :default_amount
64 | attr_reader :line_items, :tagged_items
65 |
66 | def initialize(occurred_on, actor_name, default_amount)
67 | @occurred_on = occurred_on
68 | @actor_name = actor_name
69 | @default_amount = default_amount
70 | @line_items = []
71 | @tagged_items = []
72 | @check_number = @memo = nil
73 | end
74 |
75 | def check(number)
76 | @check_number = number
77 | self
78 | end
79 |
80 | def memo(text=nil)
81 | if text
82 | @memo = text
83 | self
84 | else
85 | @memo
86 | end
87 | end
88 |
89 | def copy(post)
90 | @memo = post.memo && post.memo.dup
91 | @default_amount = post.default_amount
92 | @line_items = Marshal.load(Marshal.dump(post.line_items))
93 | @tagged_items = Marshal.load(Marshal.dump(post.tagged_items))
94 | self
95 | end
96 |
97 | def source(account, bucket)
98 | make_line_items(account, "payment_source", bucket, true)
99 | self
100 | end
101 |
102 | def repay(account, bucket)
103 | make_line_items(account, "credit_options", bucket, true)
104 | line_items << { :role => "aside", :account => account, :bucket_id => "r:aside", :amount => default_amount }
105 | self
106 | end
107 |
108 | def deposit(account, bucket)
109 | make_line_items(account, "deposit", bucket, false)
110 | self
111 | end
112 |
113 | def from(account, bucket)
114 | make_line_items(account, "transfer_from", bucket, true)
115 | self
116 | end
117 |
118 | def to(account, bucket)
119 | make_line_items(account, "transfer_to", bucket, false)
120 | self
121 | end
122 |
123 | def reallocate(account, direction, primary, buckets)
124 | balance = make_line_items(account, "reallocate_#{direction}", buckets, direction == :to)
125 | line_items << { :role => "primary", :account => account, :bucket => primary,
126 | :amount => (direction == :from ? -1 : 1) * balance }
127 | self
128 | end
129 |
130 | def tag(*tags)
131 | partials = tags.last.is_a?(Hash) ? tags.pop : {}
132 |
133 | tags.each do |name|
134 | tagged_items << { :tag_id => "n:#{name}", :amount => default_amount }
135 | end
136 |
137 | partials.each do |name, amount|
138 | tagged_items << { :tag_id => "n:#{name}", :amount => amount }
139 | end
140 |
141 | self
142 | end
143 |
144 | private
145 |
146 | def each_item(items)
147 | sum = 0
148 |
149 | Array(items).each do |item|
150 | bucket, amount = Array(item)
151 | amount ||= default_amount
152 | sum += amount
153 | yield bucket, amount
154 | end
155 |
156 | @default_amount ||= sum
157 | return sum
158 | end
159 |
160 | def make_line_items(account, role, items, expense)
161 | each_item(items) do |name, amount|
162 | line_items << { :role => role, :account => account, :bucket => name,
163 | :amount => (expense ? -1 : 1) * amount }
164 | end
165 | end
166 | end
167 | end
168 |
169 |
--------------------------------------------------------------------------------
/vendor/plugins/cached_externals/README.rdoc:
--------------------------------------------------------------------------------
1 | = Cached Externals
2 |
3 | This Capistrano extension provides yet another way to manage your application's external dependencies.
4 |
5 | External dependencies are a necessary evil. If you're doing Rails development, you'll automatically be dependent on the Rails framework itself, at the very least, and you probably have a handful or two of plugins that you're using as well. Traditionally, the solution to these has been to either install those dependencies globally (which breaks down when you have multiple apps that need different versions of Rails or the plugins), or to bundle them into your application directly via freeze:gems or the like (which tends to bloat your repository).
6 |
7 | Now, bloating a repository is, in itself, not a big deal. However, when it comes time to deploy your application, more data in the repository means more data to check out, resulting in longer deploy times. Some deployment strategies (like remote_cache) can help, but you're _still_ going to be copying all of that data every time you deploy your application.
8 |
9 | One solution is to use a deployment strategy like FastRemoteCache (http://github.com/37signals/fast_remote_cache), which employs hard-links instead of copying files. But your deploys will go _even faster_ if you didn't have to worry about moving or linking all the files in your external dependencies.
10 |
11 | That's where this Cached Externals plugin comes in. It capitalizes on one key concept: your external dependencies are unlikely to change frequently. Because they don't change frequently, it makes more sense to just check them out once, and simply refer to that checkout via symlinks.
12 |
13 | This means your deploys only have to add a single symbolic link for each external dependency, which can mean orders of magnitude less work for a deploy to do.
14 |
15 | == Dependencies
16 |
17 | * Capistrano 2 or later (http://www.capify.org)
18 |
19 | == Assumptions
20 |
21 | The Cached Externals plugin assumes that your external dependencies are either a gem, or under version control somewhere, and the gem sources and repositories are accessible both locally and from your deployment targets.
22 |
23 | == Usage
24 |
25 | When using this with Rails applications, all you need to do is install this as a plugin in your application. Make sure your Capfile has a line like the following:
26 |
27 | Dir['vendor/plugins/*/recipes/*.rb'].each { |plugin| load(plugin) }
28 |
29 | It should be just before the line that loads the 'config/deploy' file. If that line is not there, add it (or rerun "capify .").
30 |
31 | If you are not using this with a Rails application, you'll need to explicitly load recipes/cached_externals.rb in your Capfile.
32 |
33 | Next, tell Capistrano about your external dependencies. This is done via a YAML file: config/externals.yml. It describes a hash of path names that describe the external repository. For example:
34 |
35 | ---
36 | vendor/rails:
37 | :type: git
38 | :repository: git://github.com/rails/rails.git
39 | :revision: f2d8d13c6495f2a9b3bbf3b50d869c0e5b25c207
40 | vendor/plugins/exception_notification:
41 | :type: git
42 | :repository: git://github.com/rails/exception_notification.git
43 | :revision: ed0b914ff493f9137abc4f68ee08e3c3cd7a3211
44 | vendor/libs/tzinfo:
45 | :type: gem
46 | :version: 0.3.18
47 |
48 | Specify as many as you like. Although it is best to specify an exact revision, you can also give any revision identifier that capistrano understands (git branches, HEAD, etc.). If you do, though, Capistrano will have to resolve those pseudo-revision identifiers every time, which can slow things down a little.
49 |
50 | Once you've got your externals.yml in place, you'll need to clear away the cruft. For example, if you were going to put vendor/rails as a cached external dependency, you'd need to first remove vendor/rails from your working copy. After clearing away all of the in-repository copies of your external dependencies, you just tell capistrano to load up the cached copies:
51 |
52 | cap local externals:setup
53 |
54 | That will cause Capistrano to read your externals.yml, checkout your dependencies, and create symlinks to them. When run locally like that, the dependencies will be checked out to ../shared/externals (e.g., up a directory from your project root).
55 |
56 | Any time you update your config/externals.yml, you'll need to rerun that command. If an external hasn't changed revision, Capistrano will notice that and will not check it out again--only those that have changed will be updated.
57 |
58 | When you deploy your application, these externals will be checked out into #{shared_path}/externals, and again, nothing will be checked out if the dependency's revision matches what has already been cached.
59 |
60 | == Tips
61 |
62 | For the fastest possible deploys, always give an exact revision identifier. This way, Capistrano may not have to query the dependency's repository at all, if nothing has changed.
63 |
64 | Also, if you're using git, it can be a pain to change branches with this plugin, because different branches may have different dependencies, or (even worse) the same dependency but at different revisions. This plugin provides a Rake task, "git:hooks:install", that installs a couple of git hook scripts: post-checkout and post-merge. (If you already have scripts defined for those, back them up before running this task, because they'll get clobbered!) These hooks will then make it so that any time you switch branches, or do a "git pull" or "git merge", the "cap local externals:setup" task will also get run, keeping your external dependencies in sync.
65 |
66 | == License
67 |
68 | This code is released under the MIT license, and is copyright (c) 2008 by 37signals, LLC. Please see the accompanying LICENSE file for the full text of the license.
69 |
--------------------------------------------------------------------------------
/app/models/account.rb:
--------------------------------------------------------------------------------
1 | class Account < ActiveRecord::Base
2 | DEFAULT_BUCKET_NAME = "General"
3 |
4 | # When should the levels of credit cards be reached (in %)
5 | DEFAULT_LIMIT_VALUES = {
6 | :critical => 100,
7 | :high => 80,
8 | :medium => 30,
9 | :low => 0
10 | }
11 |
12 | belongs_to :subscription
13 | belongs_to :author, :class_name => "User", :foreign_key => "user_id"
14 |
15 | attr_accessor :starting_balance
16 | attr_accessible :name, :role, :limit, :starting_balance
17 |
18 | validates_presence_of :name
19 | validates_presence_of :limit, :if => :credit_card?
20 | validates_uniqueness_of :name, :scope => :subscription_id, :case_sensitive => false
21 |
22 | has_many :buckets do
23 | def for_role(role, user)
24 | role = role.downcase
25 | find_by_role(role) || create({:name => role.capitalize, :role => role}, :author => user)
26 | end
27 |
28 | def sorted
29 | sort_by(&:name)
30 | end
31 |
32 | def default
33 | detect { |bucket| bucket.role == "default" }
34 | end
35 |
36 | def with_defaults
37 | buckets = to_a.dup
38 | buckets << Bucket.default unless buckets.any? { |bucket| bucket.role == "default" }
39 | buckets << Bucket.aside unless buckets.any? { |bucket| bucket.role == "aside" }
40 | return buckets
41 | end
42 | end
43 |
44 | has_many :line_items
45 | has_many :statements
46 |
47 | has_many :account_items, :extend => CategorizedItems
48 |
49 | after_create :create_default_buckets, :set_starting_balance
50 |
51 | def self.template
52 | new :name => "Account name (e.g. Checking)", :role => "checking | credit-card | nil",
53 | :starting_balance => { :amount => 0, :occurred_on => Date.today }
54 | end
55 |
56 | def credit_card?
57 | role == 'credit-card'
58 | end
59 |
60 | def checking?
61 | role == 'checking'
62 | end
63 |
64 | def available_balance
65 | @available_balance ||= balance - unavailable_balance
66 | end
67 |
68 | def unavailable_balance
69 | @unavailable_balance ||= begin
70 | aside = buckets.detect { |bucket| bucket.role == 'aside' }
71 | aside && aside.balance > 0 ? aside.balance : 0
72 | end
73 | end
74 |
75 | def destroy
76 | transaction do
77 | cleanup_account_items
78 | cleanup_line_items
79 | cleanup_buckets
80 | cleanup_tagged_items
81 | cleanup_events
82 |
83 | Account.delete(id)
84 | end
85 | end
86 |
87 | def to_xml(options={})
88 | if new_record?
89 | options[:only] = %w(name role)
90 | options[:procs] = Proc.new do |opts|
91 | starting_balance.to_xml(opts.merge(:root => "starting-balance"))
92 | end
93 | end
94 |
95 | super(options)
96 | end
97 |
98 | protected
99 |
100 | def create_default_buckets
101 | buckets.create({:name => DEFAULT_BUCKET_NAME, :role => "default"}, :author => author)
102 | end
103 |
104 | def set_starting_balance
105 | if starting_balance && !starting_balance[:amount].to_i.zero?
106 | amount = starting_balance[:amount].to_i
107 | role = amount > 0 ? "deposit" : "payment_source"
108 | subscription.events.create({:occurred_on => starting_balance[:occurred_on],
109 | :actor_name => "Starting balance",
110 | :line_items => [{:account_id => id, :bucket_id => buckets.default.id,
111 | :amount => amount, :role => role}]
112 | }, :user => author)
113 | reload # make sure the balance is set correctly
114 | end
115 | end
116 |
117 | private
118 |
119 | def cleanup_line_items
120 | LineItem.delete_all :account_id => id
121 | end
122 |
123 | def cleanup_account_items
124 | items = account_items.find(:all, :include => { :event => [:line_items, :account_items] })
125 |
126 | items.each do |item|
127 | event = item.event
128 | next unless event.account_items.length > 1
129 |
130 | case event.role
131 | when :transfer then
132 | # if half of a transfer is being deleted, convert the other half to either a deposit
133 | # or an expense, depending on whether the half being deleted is the negative or
134 | # positive half.
135 | event.line_items.update_all(:role => (item.amount < 0 ? 'deposit' : 'payment_source'))
136 |
137 | when :expense then
138 | # if the credit_options account is being deleted, we don't need to do anything,
139 | # but if the payment_source account is being deleted, then we need to convert the
140 | # credit_options items to a bucket reallocation.
141 | if event.account_for(:payment_source) == self
142 | event.line_items.update_all({:role => 'primary'}, :role => 'aside')
143 | event.line_items.update_all({:role => 'reallocate_to'}, :role => 'credit_options')
144 | end
145 | end
146 | end
147 |
148 | AccountItem.delete_all :account_id => id
149 | end
150 |
151 | def cleanup_buckets
152 | Bucket.delete_all :account_id => id
153 | end
154 |
155 | def cleanup_tagged_items
156 | tagged_items = TaggedItem.find_by_sql(<<-SQL.squish)
157 | SELECT t.* FROM tagged_items t LEFT JOIN events e ON t.event_id = e.id
158 | WHERE e.subscription_id = #{subscription_id}
159 | AND NOT EXISTS (
160 | SELECT * FROM account_items a WHERE a.event_id = e.id)
161 | SQL
162 |
163 | tagged_items.each { |item| item.destroy }
164 | end
165 |
166 | def cleanup_events
167 | connection.delete(<<-SQL.squish)
168 | DELETE FROM events
169 | WHERE subscription_id = #{subscription_id}
170 | AND NOT EXISTS (
171 | SELECT * FROM account_items a WHERE a.event_id = events.id)
172 | SQL
173 | end
174 | end
175 |
--------------------------------------------------------------------------------
/test/functional/buckets_controller_test.rb:
--------------------------------------------------------------------------------
1 | require 'test_helper'
2 |
3 | class BucketsControllerTest < ActionController::TestCase
4 | setup :login_default_user
5 |
6 | test "index should 404 when when user without permissions requests page" do
7 | get :index, :account_id => accounts(:tim_checking).id
8 | assert_response :missing
9 | end
10 |
11 | test "index should load account and subscription and render page" do
12 | get :index, :account_id => accounts(:john_checking).id
13 | assert_response :success
14 | assert_template "buckets/index"
15 | assert_equal subscriptions(:john), assigns(:subscription)
16 | assert_equal accounts(:john_checking), assigns(:account)
17 | assert_equal accounts(:john_checking).buckets.length, assigns(:buckets).length
18 | assert !assigns(:filter).any?
19 | end
20 |
21 | test "index with filter options should set filter and return only matching buckets" do
22 | get :index, :account_id => accounts(:john_checking).id, :expenses => true
23 | assert_response :success
24 | assert_template "buckets/index"
25 | assert assigns(:filter).any?
26 | assert_equal %w(Aside Dining), assigns(:buckets).map(&:name).sort
27 | end
28 |
29 | test "show should 404 when when user without permissions requests page" do
30 | get :show, :id => buckets(:tim_checking_general).id
31 | assert_response :missing
32 | end
33 |
34 | test "show should load bucket, account, and subscription and render page" do
35 | get :show, :id => buckets(:john_checking_dining).id
36 | assert_response :success
37 | assert_template "buckets/show"
38 | assert_equal subscriptions(:john), assigns(:subscription)
39 | assert_equal accounts(:john_checking), assigns(:account)
40 | assert_equal buckets(:john_checking_dining), assigns(:bucket)
41 | end
42 |
43 | test "update should 404 when user without permissions requests page" do
44 | xhr :put, :update, :id => buckets(:tim_checking_general).id, :bucket => { :name => "Hi!" }
45 | assert_response :missing
46 | assert_equal "General", buckets(:tim_checking_general, :reload).name
47 | end
48 |
49 | test "update should change bucket name and render javascript" do
50 | xhr :put, :update, :id => buckets(:john_checking_general).id, :bucket => { :name => "Hi!" }
51 | assert_response :success
52 | assert_template "buckets/update.js.rjs"
53 | assert_equal subscriptions(:john), assigns(:subscription)
54 | assert_equal accounts(:john_checking), assigns(:account)
55 | assert_equal buckets(:john_checking_general), assigns(:bucket)
56 | assert_equal "Hi!", buckets(:john_checking_general, :reload).name
57 | end
58 |
59 | test "destroy without receiver_id should 404" do
60 | assert_no_difference "Bucket.count" do
61 | delete :destroy, :id => buckets(:john_checking_dining).id
62 | end
63 | assert_response :missing
64 | end
65 |
66 | test "destroy should assimilate line items and destroy bucket" do
67 | assert_difference "Bucket.count", -1 do
68 | delete :destroy, :id => buckets(:john_checking_dining).id,
69 | :receiver_id => buckets(:john_checking_groceries).id
70 | end
71 | assert_redirected_to(buckets(:john_checking_groceries))
72 | assert !Bucket.exists?(buckets(:john_checking_dining).id)
73 | assert buckets(:john_checking_groceries), line_items(:john_lunch_checking_dining).bucket
74 | end
75 |
76 | # == API tests ========================================================================
77 |
78 | test "index via API should return bucket list for account" do
79 | get :index, :account_id => accounts(:john_checking).id, :format => "xml"
80 | assert_response :success
81 | xml = Hash.from_xml(@response.body)
82 | assert_equal accounts(:john_checking).buckets.length, xml["buckets"].length
83 | end
84 |
85 | test "show via API should return bucket record" do
86 | get :show, :id => buckets(:john_checking_dining).id, :format => "xml"
87 | assert_response :success
88 | xml = Hash.from_xml(@response.body)
89 | assert_equal buckets(:john_checking_dining).id, xml["bucket"]["id"]
90 | end
91 |
92 | test "new via API should return a template XML response" do
93 | get :new, :account_id => accounts(:john_checking).id, :format => "xml"
94 | assert_response :success
95 | xml = Hash.from_xml(@response.body)
96 | assert xml["bucket"]
97 | assert !xml["bucket"]["id"]
98 | end
99 |
100 | test "create via API should return 422 with error messages when validations fail" do
101 | post :create,
102 | :account_id => accounts(:john_checking).id,
103 | :bucket => { :name => "Dining", :role => "" },
104 | :format => "xml"
105 | assert_response :unprocessable_entity
106 | xml = Hash.from_xml(@response.body)
107 | assert xml["errors"].any?
108 | end
109 |
110 | test "create via API should create record and respond with 201" do
111 | assert_difference "accounts(:john_checking).buckets.count" do
112 | post :create,
113 | :account_id => accounts(:john_checking).id,
114 | :bucket => { :name => "Utilities", :role => "" },
115 | :format => "xml"
116 | assert_response :created
117 | assert @response.headers["Location"]
118 | end
119 | end
120 |
121 | test "update via API should update record and respond with 200" do
122 | put :update, :id => buckets(:john_checking_dining).id, :bucket => { :name => "Hi!" }, :format => "xml"
123 | assert_response :success
124 | xml = Hash.from_xml(@response.body)
125 | assert_equal "Hi!", xml["bucket"]["name"]
126 | end
127 |
128 | test "update via API with validation errors should respond with 422" do
129 | put :update, :id => buckets(:john_checking_dining).id, :bucket => { :name => "Groceries" }, :format => "xml"
130 | assert_response :unprocessable_entity
131 | xml = Hash.from_xml(@response.body)
132 | assert xml["errors"].any?
133 | end
134 |
135 | test "destroy via API should remove record and respond with 200" do
136 | assert_difference "Bucket.count", -1 do
137 | delete :destroy, :id => buckets(:john_checking_dining).id,
138 | :receiver_id => buckets(:john_checking_groceries).id,
139 | :format => "xml"
140 | assert_response :success
141 | end
142 | end
143 | end
144 |
--------------------------------------------------------------------------------
/test/functional/accounts_controller_test.rb:
--------------------------------------------------------------------------------
1 | require 'test_helper'
2 |
3 | class AccountsControllerTest < ActionController::TestCase
4 | setup :login_default_user
5 |
6 | test "show should 404 when when user without permissions requests page" do
7 | get :show, :id => accounts(:tim_checking).id
8 | assert_response :missing
9 | end
10 |
11 | test "new should 404 when user without permissions requests page" do
12 | get :new, :subscription_id => subscriptions(:tim).id
13 | assert_response :missing
14 | end
15 |
16 | test "create should 404 when when user without permissions requests page" do
17 | assert_no_difference "Account.count" do
18 | post :create, {
19 | :subscription_id => subscriptions(:tim).id,
20 | :account => { :name => "Savings", :role => "saving" } }
21 | assert_response :missing
22 | end
23 | end
24 |
25 | test "show should load account and subscription and render page" do
26 | get :show, :id => accounts(:john_checking).id
27 | assert_response :success
28 | assert_template "accounts/show"
29 | assert_equal accounts(:john_checking), assigns(:account)
30 | assert_equal subscriptions(:john), assigns(:subscription)
31 | end
32 |
33 | test "new should load subscription and render page" do
34 | get :new, :subscription_id => subscriptions(:john).id
35 | assert_response :success
36 | assert_template "accounts/new"
37 | assert_equal subscriptions(:john), assigns(:subscription)
38 | end
39 |
40 | test "create should load subscription and create account and redirect" do
41 | assert_difference "subscriptions(:john).accounts.count" do
42 | post :create, {
43 | :subscription_id => subscriptions(:john).id,
44 | :account => { :name => "Mortgage", :role => "" } }
45 | assert_redirected_to(subscription_url(subscriptions(:john)))
46 | end
47 |
48 | assert_equal subscriptions(:john), assigns(:subscription)
49 | assert assigns(:account)
50 | end
51 |
52 | test "create with invalid record should render 'new' action" do
53 | assert_no_difference "Account.count" do
54 | post :create, :subscription_id => subscriptions(:john).id,
55 | :account => { :name => "Checking", :role => "checking" }
56 | assert_response :success
57 | assert_template "accounts/new"
58 | assert assigns(:account) && !assigns(:account).valid?
59 | end
60 | end
61 |
62 | test "destroy should 404 when user without permission requests page" do
63 | assert_no_difference "Account.count" do
64 | delete :destroy, :id => accounts(:tim_checking).id
65 | assert_response :missing
66 | end
67 | end
68 |
69 | test "destroy should remove account and redirect" do
70 | assert_difference "Account.count", -1 do
71 | delete :destroy, :id => accounts(:john_mastercard).id
72 | assert_redirected_to(subscription_url(subscriptions(:john)))
73 | end
74 | end
75 |
76 | test "update should 404 when user without permissions requests page" do
77 | xhr :put, :update, :id => accounts(:tim_checking).id, :account => { :name => "Hi!" }
78 | assert_response :missing
79 | assert_equal "Checking", accounts(:tim_checking, :reload).name
80 | end
81 |
82 | test "update should change account name and render javascript" do
83 | xhr :put, :update, :id => accounts(:john_checking).id, :account => { :name => "Hi!" }
84 | assert_response :success
85 | assert_template "accounts/update.js.rjs"
86 | assert_equal subscriptions(:john), assigns(:subscription)
87 | assert_equal accounts(:john_checking), assigns(:account)
88 | assert_equal "Hi!", accounts(:john_checking, :reload).name
89 | end
90 |
91 | # == API tests ========================================================================
92 |
93 | test "index via API should return account list" do
94 | get :index, :subscription_id => subscriptions(:john).id, :format => "xml"
95 | assert_response :success
96 | xml = Hash.from_xml(@response.body)
97 | assert_equal subscriptions(:john).accounts.length, xml["accounts"].length
98 | end
99 |
100 | test "show via API should return account record" do
101 | get :show, :id => accounts(:john_checking).id, :format => "xml"
102 | assert_response :success
103 | xml = Hash.from_xml(@response.body)
104 | assert_equal accounts(:john_checking).id, xml["account"]["id"]
105 | end
106 |
107 | test "new via API should return a template XML response" do
108 | get :new, :subscription_id => subscriptions(:john).id, :format => "xml"
109 | assert_response :success
110 | xml = Hash.from_xml(@response.body)
111 | assert xml["account"]
112 | assert !xml["account"]["id"]
113 | end
114 |
115 | test "create via API should return 422 with error messages when validations fail" do
116 | post :create,
117 | :subscription_id => subscriptions(:john).id,
118 | :account => { :name => "Checking", :role => "" },
119 | :format => "xml"
120 | assert_response :unprocessable_entity
121 | xml = Hash.from_xml(@response.body)
122 | assert xml["errors"].any?
123 | end
124 |
125 | test "create via API should create record and respond with 201" do
126 | assert_difference "subscriptions(:john).accounts.count" do
127 | post :create,
128 | :subscription_id => subscriptions(:john).id,
129 | :account => { :name => "Mortgage", :role => "" },
130 | :format => "xml"
131 | assert_response :created
132 | assert @response.headers["Location"]
133 | end
134 | end
135 |
136 | test "update via API with validation errors should respond with 422" do
137 | put :update, :id => accounts(:john_checking).id, :account => { :name => "Mastercard" }, :format => "xml"
138 | assert_response :unprocessable_entity
139 | xml = Hash.from_xml(@response.body)
140 | assert xml["errors"].any?
141 | end
142 |
143 | test "update via API should update record and respond with 200" do
144 | put :update, :id => accounts(:john_checking).id, :account => { :name => "Hi!" }, :format => "xml"
145 | assert_response :success
146 | xml = Hash.from_xml(@response.body)
147 | assert_equal "Hi!", xml["account"]["name"]
148 | end
149 |
150 | test "destroy via API should remove record and respond with 200" do
151 | assert_difference "Account.count", -1 do
152 | delete :destroy, :id => accounts(:john_mastercard).id, :format => "xml"
153 | assert_response :success
154 | end
155 | end
156 | end
157 |
--------------------------------------------------------------------------------