diff --git a/TODO b/TODO index cd50590..21bb277 100644 --- a/TODO +++ b/TODO @@ -18,7 +18,6 @@ FEATURES that would be nice to have someday (in no particular order) * better 404 and 500 error pages * searching * reporting -* account reconciliation * transaction templates ("saved" or "memorized" transactions) * scheduled transactions (occur automatically at specified intervals) * print stylesheet @@ -32,3 +31,5 @@ FEATURES that would be nice to have someday (in no particular order) * make bucket reallocation work from bucket perma * support for multiple 'aside' buckets in a single account * graphical icons to replace the textual icons for various actions +* add/edit transactions from the reconciliation view +* statement API diff --git a/app/concerns/categorized_items.rb b/app/concerns/categorized_items.rb new file mode 100644 index 0000000..a1f1243 --- /dev/null +++ b/app/concerns/categorized_items.rb @@ -0,0 +1,13 @@ +module CategorizedItems + def deposits + @deposits ||= to_a.select { |item| item.amount > 0 } + end + + def checks + @checks ||= to_a.select { |item| item.amount < 0 && item.event.check_number.present? } + end + + def expenses + @expenses ||= to_a.select { |item| item.amount < 0 && item.event.check_number.blank? } + end +end diff --git a/app/controllers/statements_controller.rb b/app/controllers/statements_controller.rb new file mode 100644 index 0000000..44925ff --- /dev/null +++ b/app/controllers/statements_controller.rb @@ -0,0 +1,54 @@ +class StatementsController < ApplicationController + before_filter :find_account, :only => %w(index new create) + before_filter :find_statement, :only => %w(show edit update destroy) + + def index + @statements = account.statements.balanced + end + + def new + @statement = account.statements.build(:ending_balance => account.balance, + :occurred_on => Date.today) + end + + def create + @statement = account.statements.create(params[:statement]) + redirect_to(edit_statement_url(@statement)) + end + + def show + end + + def edit + @uncleared = account.account_items.uncleared(:with => statement, :include => :event) + end + + def update + statement.update_attributes(params[:statement]) + redirect_to(account) + end + + def destroy + statement.destroy + redirect_to(account) + end + + protected + + attr_reader :uncleared, :statements + helper_method :uncleared, :statements + + attr_reader :account, :statement + helper_method :account, :statement + + def find_account + @account = Account.find(params[:account_id]) + @subscription = user.subscriptions.find(@account.subscription_id) + end + + def find_statement + @statement = Statement.find(params[:id]) + @account = @statement.account + @subscription = user.subscriptions.find(@account.subscription_id) + end +end diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index 2c2b0cd..d7daaaa 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -24,4 +24,8 @@ def application_last_deployed "(not deployed)" end end + + def format_cents(amount, options={}) + number_to_currency(amount/100.0, options) + end end diff --git a/app/helpers/statements_helper.rb b/app/helpers/statements_helper.rb new file mode 100644 index 0000000..6ca3dc2 --- /dev/null +++ b/app/helpers/statements_helper.rb @@ -0,0 +1,7 @@ +module StatementsHelper + def uncleared_row_class(item) + classes = [cycle('odd', 'even')] + classes << "cleared" if item.statement_id + classes.join(" ") + end +end diff --git a/app/models/account.rb b/app/models/account.rb index 32b423e..b9874f5 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -37,7 +37,9 @@ def with_defaults end has_many :line_items - has_many :account_items + has_many :statements + + has_many :account_items, :extend => CategorizedItems after_create :create_default_buckets, :set_starting_balance diff --git a/app/models/account_item.rb b/app/models/account_item.rb index 50c4cf1..a54d413 100644 --- a/app/models/account_item.rb +++ b/app/models/account_item.rb @@ -10,10 +10,31 @@ class AccountItem < ActiveRecord::Base belongs_to :event belongs_to :account + belongs_to :statement after_create :increment_balance before_destroy :decrement_balance + named_scope :uncleared, lambda { |*args| AccountItem.options_for_uncleared(*args) } + + def self.options_for_uncleared(*args) + raise ArgumentError, "too many arguments #{args.length} for 1" if args.length > 1 + + options = args.first || {} + raise ArgumentError, "expected Hash, got #{options.class}" unless options.is_a?(Hash) + options = options.dup + + conditions = "statement_id IS NULL" + parameters = [] + + if options[:with] + conditions = "(#{conditions} OR statement_id = ?)" + parameters << options[:with] + end + + { :conditions => [conditions, *parameters], :include => options[:include] } + end + protected def increment_balance diff --git a/app/models/statement.rb b/app/models/statement.rb new file mode 100644 index 0000000..ebd6fb9 --- /dev/null +++ b/app/models/statement.rb @@ -0,0 +1,92 @@ +class Statement < ActiveRecord::Base + belongs_to :account + has_many :account_items, :extend => CategorizedItems, :dependent => :nullify + + before_create :initialize_starting_balance + after_save :associate_account_items_with_self + + named_scope :pending, :conditions => { :balanced_at => nil } + named_scope :balanced, :conditions => "balanced_at IS NOT NULL" + + attr_accessible :occurred_on, :ending_balance, :cleared + + validates_presence_of :occurred_on, :ending_balance + + def ending_balance=(amount) + if amount.is_a?(Float) || amount =~ /[.,]/ + amount = (amount.to_s.tr(",", "").to_f * 100).to_i + end + + super(amount) + end + + def balance + ending_balance + end + + def balanced?(reload=false) + unsettled_balance(reload).zero? + end + + def settled_balance(reload=false) + @settled_balance = nil if reload + @settled_balance ||= account_items.to_a.sum(&:amount) + end + + def unsettled_balance(reload=false) + @unsettled_balance = nil if reload + @unsettled_balance ||= starting_balance + settled_balance(reload) - ending_balance + end + + def cleared=(ids) + @ids_to_clear = ids + @already_updated = false + end + + protected + + def initialize_starting_balance + self.starting_balance ||= account.statements.balanced.last.try(:ending_balance) || 0 + end + + def associate_account_items_with_self + return if @already_updated + @already_updated = true + + account_items.clear + + ids = connection.select_values(sanitize_sql([<<-SQL.squish, account_id, ids_to_clear])) + SELECT ai.id + FROM account_items ai + WHERE ai.account_id = ? + AND ai.id IN (?) + SQL + + connection.update(sanitize_sql([<<-SQL.squish, id, ids])) + UPDATE account_items + SET statement_id = ? + WHERE id IN (?) + SQL + + account_items.reset + + if @ids_to_clear + if balanced?(true) && !balanced_at + update_attribute :balanced_at, Time.now.utc + elsif !balanced? && balanced_at + update_attribute :balanced_at, nil + end + end + end + + private + + def sanitize_sql(sql) + self.class.send(:sanitize_sql, sql) + end + + def ids_to_clear + @ids_to_clear || [] + end + +end diff --git a/app/views/accounts/_name.html.haml b/app/views/accounts/_name.html.haml index 86f97d8..4bdd209 100644 --- a/app/views/accounts/_name.html.haml +++ b/app/views/accounts/_name.html.haml @@ -1,2 +1,13 @@ -%span.actions== #{link_to_function("Rename", "Accounts.rename(#{account_path(account).to_json}, #{account.name.to_json}, #{form_authenticity_token.to_json})")} | #{link_to("Delete", account_path(account), :method => :delete, :confirm => "Are you sure you want to delete this account?")} +%span.actions + - if account.statements.pending.any? + = link_to("Resume reconciling", edit_statement_path(account.statements.pending.first)) + - else + = link_to("Reconcile", new_account_statement_path(account)) + | + - if account.statements.balanced.any? + = link_to("Prior statements", account_statements_path(account)) + | + = link_to_function("Rename", "Accounts.rename(#{account_path(account).to_json}, #{account.name.to_json}, #{form_authenticity_token.to_json})") + | + = link_to("Delete", account_path(account), :method => :delete, :confirm => "Are you sure you want to delete this account?") &= account.name diff --git a/app/views/events/_balance.html.haml b/app/views/events/_balance.html.haml index 35bd256..e341735 100644 --- a/app/views/events/_balance.html.haml +++ b/app/views/events/_balance.html.haml @@ -1,6 +1,10 @@ %tr.current_balance - %th.date= Date.today.strftime("%Y-%m-%d") - %th Current balance + - if container.is_a?(Statement) + %th.date= statement.occurred_on.strftime("%Y-%m-%d") + %th Ending balance + - else + %th.date= Date.today.strftime("%Y-%m-%d") + %th Current balance %th.number.total.balance{:colspan => 2}= balance_cell(container, :tag => "span", :id => "balance") %tr.spacer %td{:colspan => 4} diff --git a/app/views/statements/_subtotal.html.haml b/app/views/statements/_subtotal.html.haml new file mode 100644 index 0000000..a103af0 --- /dev/null +++ b/app/views/statements/_subtotal.html.haml @@ -0,0 +1,8 @@ +.subtotal + .settled + Subtotal: + %span.subtotal_dollars= format_cents(subtotal) + .remaining + Remaining: + %span.remaining_dollars{:class => statement.unsettled_balance.zero? ? "balanced" : nil} + = format_cents(statement.unsettled_balance) diff --git a/app/views/statements/_uncleared.html.haml b/app/views/statements/_uncleared.html.haml new file mode 100644 index 0000000..adafcc1 --- /dev/null +++ b/app/views/statements/_uncleared.html.haml @@ -0,0 +1,9 @@ +%tr{:id => dom_id(uncleared), :class => uncleared_row_class(uncleared)} + %td.checkbox= check_box_tag "statement[cleared][]", uncleared.id, uncleared.statement_id, :id => dom_id(uncleared, :check), :onclick => "Statements.toggleCleared(#{uncleared.id})" + %td.date{:onclick => "Statements.clickItem(#{uncleared.id})"}= uncleared.occurred_on.strftime("%Y-%m-%d") + - if uncleared.event.check_number + %td.check{:onclick => "Statements.clickItem(#{uncleared.id})"}= "#" + uncleared.event.check_number.to_s + %td.actor{:onclick => "Statements.clickItem(#{uncleared.id})"}&= uncleared.event.actor + %td.number{:onclick => "Statements.clickItem(#{uncleared.id})"} + %span{:style => "display: none;", :id => dom_id(uncleared, :amount)}= uncleared.amount + = format_cents(uncleared.amount.abs) diff --git a/app/views/statements/edit.html.haml b/app/views/statements/edit.html.haml new file mode 100644 index 0000000..14ead84 --- /dev/null +++ b/app/views/statements/edit.html.haml @@ -0,0 +1,55 @@ +#data.content + %h2== Balance your statement + + .statement.edit.form + + - form_for(statement) do |form| + + %table.general + %tr + %th.occurred_on Statement date + %th.starting_balance Starting balance + %th.ending_balance Ending balance + %tr + %td.occurred_on= form.calendar_date_select :occurred_on, :size => 10 + %td#starting_balance.starting_balance.number= format_cents(statement.starting_balance) + %td.ending_balance= "$" + form.text_field(:ending_balance, :size => 8, :class => "number", :value => format_cents(statement.ending_balance, :unit => ""), :onchange => "Statements.updateBalances()") + + - if uncleared.deposits.any? + %fieldset#deposits + %legend Deposits + + .uncleared.deposits + %table= render :partial => "statements/uncleared", :collection => uncleared.deposits + + = render :partial => "statements/subtotal", :object => statement.account_items.deposits.sum(&:amount) + + - if uncleared.checks.any? + %fieldset#checks + %legend Checks + + .uncleared.checks + %table= render :partial => "statements/uncleared", :collection => uncleared.checks + + = render :partial => "statements/subtotal", :object => statement.account_items.checks.sum(&:amount) + + - if uncleared.expenses.any? + %fieldset#expenses + %legend Other expenses + + .uncleared.expenses + %table= render :partial => "statements/uncleared", :collection => uncleared.expenses + + = render :partial => "statements/subtotal", :object => statement.account_items.expenses.sum(&:amount) + + #balanced{:style => visible?(statement.balanced?)} + %h3 Congratulations! + + %p Your records exactly match your account statement, and everything balances. + + %p= form.submit "Close out this statement" + + %p#actions{:style => visible?(!statement.balanced?)} + = form.submit "Save for later" + or + = link_to("abort this reconciliation", statement_path(statement), :method => :delete, :confirm => "Are you sure you want to discard this reconciliation?") diff --git a/app/views/statements/index.html.haml b/app/views/statements/index.html.haml new file mode 100644 index 0000000..47b24c6 --- /dev/null +++ b/app/views/statements/index.html.haml @@ -0,0 +1,17 @@ +#data.content + .navigation + = link_to "Dashboard", subscription_path(subscription) + = link_to h(account.name), account_path(account) + + %h2 Previous Statements + + - if statements.empty? + %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. + + - else + %ul + - statements.each do |statement| + %li + = link_to(statement.occurred_on.strftime("%Y-%m-%d"), statement_path(statement)) + = ":" + = format_cents(statement.ending_balance) diff --git a/app/views/statements/new.html.haml b/app/views/statements/new.html.haml new file mode 100644 index 0000000..00b8bf4 --- /dev/null +++ b/app/views/statements/new.html.haml @@ -0,0 +1,25 @@ +#data.content + %h2== Let's reconcile your #{h(account.name)} account + + .form + + - form_for([account, statement]) do |form| + + %fieldset + + %p First, take a look at the account statement from your bank or other financial institution. + + %p If you haven't reconciled in a while, make sure you start with the oldest statement and work forward. + + %p + When was the statement printed? + = form.calendar_date_select :occurred_on, :size => 10 + + %p + What is the ending balance? + = "$" + form.text_field(:ending_balance, :size => 8, :class => "number", :value => format_cents(statement.ending_balance, :unit => ""), :onchange => "this.value = Money.format(this)") + + %p + = form.submit "Go to step #2" + or + = link_to("cancel", account_path(account)) diff --git a/app/views/statements/show.html.haml b/app/views/statements/show.html.haml new file mode 100644 index 0000000..8c4ca9c --- /dev/null +++ b/app/views/statements/show.html.haml @@ -0,0 +1,15 @@ +#data.content + .navigation + = link_to "Dashboard", subscription_path(subscription) + = link_to h(account.name), account_path(account) + = link_to "Prior statements", account_statements_path(account) + + %h2 + %span.actions + = link_to("Delete", statement_path(statement), :confirm => "Are you sure you want to delete this statement?", :method => :delete) + Statement for period ending + = statement.occurred_on.strftime("%Y-%m-%d") + + %table.entries + = render :partial => "events/balance", :locals => { :container => statement } + = render(statement.account_items) diff --git a/config/routes.rb b/config/routes.rb index 216ea5a..5bd28ef 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -4,9 +4,9 @@ map.resources :subscriptions, :has_many => [:accounts, :events, :tags] map.resources :events, :has_many => :tagged_items, :member => { :update => :post } map.resources :buckets, :has_many => :events - map.resources :accounts, :has_many => [:buckets, :events] + map.resources :accounts, :has_many => [:buckets, :events, :statements] map.resources :tags, :has_many => :events - map.resources :tagged_items + map.resources :tagged_items, :statements map.connect "", :controller => "subscriptions", :action => "index" end diff --git a/db/migrate/20090421221109_add_statements.rb b/db/migrate/20090421221109_add_statements.rb new file mode 100644 index 0000000..2dc124d --- /dev/null +++ b/db/migrate/20090421221109_add_statements.rb @@ -0,0 +1,24 @@ +class AddStatements < ActiveRecord::Migration + def self.up + create_table :statements do |t| + t.integer :account_id, :null => false + t.date :occurred_on, :null => false + t.integer :starting_balance + t.integer :ending_balance + t.datetime :balanced_at + t.timestamps + end + + add_index :statements, %w(account_id occurred_on) + + add_column :account_items, :statement_id, :integer + add_index :account_items, %w(statement_id occurred_on) + end + + def self.down + drop_table :statements + + remove_index :account_items, %w(statement_id occurred_on) + remove_column :account_items, :statement_id + end +end diff --git a/db/schema.rb b/db/schema.rb index f49ca92..c6abdca 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -9,17 +9,19 @@ # # It's strongly recommended to check this file into your version control system. -ActiveRecord::Schema.define(:version => 20090404154634) do +ActiveRecord::Schema.define(:version => 20090421221109) do create_table "account_items", :force => true do |t| - t.integer "event_id", :null => false - t.integer "account_id", :null => false - t.integer "amount", :null => false - t.date "occurred_on", :null => false + t.integer "event_id", :null => false + t.integer "account_id", :null => false + t.integer "amount", :null => false + t.date "occurred_on", :null => false + t.integer "statement_id" end add_index "account_items", ["account_id", "occurred_on"], :name => "index_account_items_on_account_id_and_occurred_on" add_index "account_items", ["event_id"], :name => "index_account_items_on_event_id" + add_index "account_items", ["statement_id", "occurred_on"], :name => "index_account_items_on_statement_id_and_occurred_on" create_table "accounts", :force => true do |t| t.integer "subscription_id", :null => false @@ -75,6 +77,18 @@ add_index "line_items", ["bucket_id", "occurred_on"], :name => "index_line_items_on_bucket_id_and_occurred_on" add_index "line_items", ["event_id"], :name => "index_line_items_on_event_id" + create_table "statements", :force => true do |t| + t.integer "account_id", :null => false + t.date "occurred_on", :null => false + t.integer "starting_balance" + t.integer "ending_balance" + t.datetime "balanced_at" + t.datetime "created_at" + t.datetime "updated_at" + end + + add_index "statements", ["account_id", "occurred_on"], :name => "index_statements_on_account_id_and_occurred_on" + create_table "subscriptions", :force => true do |t| t.integer "owner_id", :null => false end diff --git a/public/javascripts/money.js b/public/javascripts/money.js index bc7bf2a..9679eac 100644 --- a/public/javascripts/money.js +++ b/public/javascripts/money.js @@ -1,21 +1,22 @@ var Money = { - dollars: function(cents) { - return (Math.abs(cents) / 100).toFixed(2); + dollars: function(cents, keepNegative) { + cents = keepNegative ? cents : Math.abs(cents); + return (cents / 100).toFixed(2); }, parse: function(field, keepNegative) { - return Money.parseValue($F(field)); + return Money.parseValue($F(field), keepNegative); }, /* don't want to use parseFloat, because we can be subject to * floating point round-off */ parseValue: function(string, keepNegative) { var value = string.gsub(/[^-+\d.]/, ""); - var match = value.match(/^([-+]?)(\d*)(?:\.(\d+))?$/); + var match = value.match(/^([-+]?)(\d*)(?:\.(\d*))?$/); if(!match) return 0; - var sign = (match[1] == "-" && keepNegative) ? -1 : 1; + var sign = ((match[1] == "-") && keepNegative) ? -1 : 1; if(match[2].length > 0) var dollars = parseInt(match[2]); else @@ -31,5 +32,45 @@ var Money = { } return sign * (dollars * 100 + cents); + }, + + format: function(field) { + return Money.formatValue(Money.parse(field, true)); + }, + + formatValue: function(cents) { + var sign = cents < 0 ? -1 : 1; + var source = String(Math.abs(cents)); + var result; + + if(source.length > 2) { + result = "." + source.slice(-2); + source = source.slice(0,-2); + } else if(source.length == 2) { + result = "." + source; + source = ""; + } else if(source.length == 1) { + result = ".0" + source; + source = ""; + } else { + result = ".00"; + } + + while(source.length > 3) { + result = "," + source.slice(-3) + result; + source = source.slice(0,-3); + } + + if(source.length > 0) { + result = source + result; + } else if(result[0] == ".") { + result = "0" + result; + } + + if(sign < 0) { + result = "-" + result; + } + + return result; } } diff --git a/public/javascripts/statements.js b/public/javascripts/statements.js new file mode 100644 index 0000000..e9d4af2 --- /dev/null +++ b/public/javascripts/statements.js @@ -0,0 +1,71 @@ +var Statements = { + clickItem: function(id) { + $('check_account_item_' + id).click(); + }, + + toggleCleared: function(id) { + var checked = $('check_account_item_' + id).checked; + var amount = parseInt($('amount_account_item_' + id).innerHTML); + var subtotalField = $('account_item_' + id).up('fieldset').down('.subtotal_dollars'); + var subtotal = Money.parseValue(subtotalField.innerHTML, true); + + if(checked) { + $('account_item_' + id).addClassName('cleared'); + subtotalField.innerHTML = "$" + Money.formatValue(subtotal + amount); + } else { + $('account_item_' + id).removeClassName('cleared'); + subtotalField.innerHTML = "$" + Money.formatValue(subtotal - amount); + } + + Statements.updateBalances(); + }, + + startingBalance: function() { + if(!Statements.cachedBalance) + Statements.cachedBalance = Money.parseValue($('starting_balance').innerHTML, true); + return Statements.cachedBalance; + }, + + endingBalance: function() { + return Money.parse($('statement_ending_balance'), true); + }, + + settled: function() { + return $$('.subtotal_dollars').inject(0, function(sum, span) { + return sum + Money.parseValue(span.innerHTML, true); + }); + }, + + remaining: function() { + return Statements.startingBalance() + Statements.settled() - Statements.endingBalance(); + }, + + updateBalances: function() { + var ending = $('statement_ending_balance'); + ending.value = Money.format(ending); + + var remaining = Statements.remaining(); + var remainingText = Money.formatValue(remaining); + + ['deposits', 'checks', 'expenses'].each(function(section) { + if($(section)) { + var span = $$("#" + section + " .remaining_dollars").first(); + + if(remaining == 0) + span.addClassName("balanced"); + else + span.removeClassName("balanced"); + + span.innerHTML = "$" + remainingText; + } + }) + + if(remaining == 0) { + $('balanced').show(); + $('actions').hide(); + } else { + $('balanced').hide(); + $('actions').show(); + } + } +} diff --git a/public/stylesheets/money.css b/public/stylesheets/money.css index dfe17bc..7cf14f5 100644 --- a/public/stylesheets/money.css +++ b/public/stylesheets/money.css @@ -617,3 +617,138 @@ body.login .notice { #blankslate p.create a { text-decoration: underline; } + +/* ------------------------------------------------------------------ * + * ACCOUNT RECONCILIATION + * ------------------------------------------------------------------ */ + +.statement table.general { + margin: 0; + margin-bottom: 1em; +} + +.statement table.general th { + font-size: 80%; + color: #777; + white-space: nowrap; +} + +.statement table.general td.occurred_on, +.statement table.general th.occurred_on { + width: 40%; +} + +.statement table.general td.starting_balance, +.statement table.general th.starting_balance { + width: 20%; + text-align: right; +} + +.statement table.general td.ending_balance, +.statement table.general th.ending_balance { + width: 40%; + text-align: right; +} + +.statement fieldset { + padding: 0; +} + +.statement .subtotal { + border-top: 1px solid #ccc; + font-size: 80%; +} + +.statement .settled { + float: right; + padding: 0.5em; +} + +.statement .remaining { + float: left; + padding: 0.5em; +} + +.statement .remaining .balanced { + color: #070; + font-weight: bold; +} + +.uncleared { + margin: 0.5em; +} + +.uncleared.expenses { + height: 12em; + overflow: auto; +} + +.uncleared.checks { + height: 12em; + overflow: auto; +} + +.uncleared.deposits { + height: 5em; + overflow: auto; +} + +.uncleared table { + margin: 0; +} + +.uncleared tr { + height: 1.4em; + cursor: pointer; +} + +.uncleared td { + font-size: 80%; + vertical-align: bottom; +} + +.uncleared td.checkbox { + width: 1px; + padding-right: 1em; +} + +.uncleared td.date { + color: black; + padding-right: 1em; +} + +.uncleared td.check { + width: 1px; + padding-right: 1em; +} + +.uncleared tr.cleared td { + text-decoration: line-through; + color: #777; +} + +.uncleared td { + padding: 0.2em; +} + +.uncleared input { + margin: 0; +} + +#balanced { + border: 1px solid #aca; + background: #cfc; + margin: 1em 0; +} + +#balanced h3 { + margin: 0; + border-bottom: 1px solid #aca; + background: #070; + color: white; + padding: 0.5em; +} + +#balanced p { + margin: 1em; +} diff --git a/test/fixtures/account_items.yml b/test/fixtures/account_items.yml index 242df43..ad53111 100644 --- a/test/fixtures/account_items.yml +++ b/test/fixtures/account_items.yml @@ -7,6 +7,7 @@ john_checking_starting_balance: account: john_checking amount: 100000 occurred_on: <%= 60.days.ago.to_date.to_s(:db) %> + statement: john john_lunch_mastercard: event: john_lunch @@ -25,6 +26,7 @@ john_bill_pay_checking: account: john_checking amount: -775 occurred_on: <%= 58.days.ago.utc.to_s(:db) %> + statement: john john_bill_pay_mastercard: event: john_bill_pay diff --git a/test/fixtures/statements.yml b/test/fixtures/statements.yml new file mode 100644 index 0000000..ff4d212 --- /dev/null +++ b/test/fixtures/statements.yml @@ -0,0 +1,34 @@ +# -------------------------------------------------------------- +# john +# -------------------------------------------------------------- + +john: + account: john_checking + occurred_on: <%= 2.weeks.ago.to_date.to_s(:db) %> + starting_balance: 0 + ending_balance: 99225 + balanced_at: <%= 1.week.ago.to_s(:db) %> + created_at: <%= 1.week.ago.to_s(:db) %> + updated_at: <%= 1.week.ago.to_s(:db) %> + +john_pending: + account: john_checking + occurred_on: <%= Date.today %> + starting_balance: 99225 + ending_balance: 125392 + balanced_at: ~ + created_at: <%= Time.now.to_s(:db) %> + updated_at: <%= Time.now.to_s(:db) %> + +# -------------------------------------------------------------- +# tim +# -------------------------------------------------------------- + +tim: + account: tim_checking + occurred_on: <%= 1.week.ago.to_date.to_s(:db) %> + starting_balance: 0 + ending_balance: 123456 + balanced_at: ~ + created_at: <%= 1.week.ago.to_s(:db) %> + updated_at: <%= 1.week.ago.to_s(:db) %> diff --git a/test/functional/statements_controller_test.rb b/test/functional/statements_controller_test.rb new file mode 100644 index 0000000..2161b1e --- /dev/null +++ b/test/functional/statements_controller_test.rb @@ -0,0 +1,102 @@ +require 'test_helper' + +class StatementsControllerTest < ActionController::TestCase + setup :login_default_user + + test "index for inaccessible account should 404" do + get :index, :account_id => accounts(:tim_checking).id + assert_response :missing + end + + test "index should list only balanced statements" do + get :index, :account_id => accounts(:john_checking).id + assert_response :success + assert_template "statements/index" + assert_equal [statements(:john)], assigns(:statements) + end + + test "new for inaccessible account should 404" do + get :new, :account_id => accounts(:tim_checking).id + assert_response :missing + end + + test "new should build template record and render" do + get :new, :account_id => accounts(:john_checking).id + assert_response :success + assert_template "statements/new" + assert assigns(:statement).new_record? + end + + test "create for inaccessible account should 404" do + assert_no_difference "Statement.count" do + post :create, :account_id => accounts(:tim_checking).id, + :statement => { :occurred_on => Date.today, :ending_balance => 1234_56 } + assert_response :missing + end + end + + test "create should create new record and redirect to edit" do + assert_difference "accounts(:john_checking, :reload).statements.size" do + post :create, :account_id => accounts(:john_checking).id, + :statement => { :occurred_on => Date.today, :ending_balance => 1234_56 } + assert_redirected_to edit_statement_url(assigns(:statement)) + end + end + + test "show for inaccessible statement should 404" do + get :show, :id => statements(:tim).id + assert_response :missing + end + + test "show should load statement record and render" do + get :show, :id => statements(:john).id + assert_response :success + assert_template "statements/show" + assert_equal statements(:john), assigns(:statement) + end + + test "edit for inaccessible statement should 404" do + get :edit, :id => statements(:tim).id + assert_response :missing + end + + test "edit should load statement record and render" do + get :edit, :id => statements(:john).id + assert_response :success + assert_template "statements/edit" + assert_equal statements(:john), assigns(:statement) + end + + test "update for inaccessible statement should 404" do + put :update, :id => statements(:tim).id, + :statement => { :occurred_on => statements(:tim).occurred_on, + :ending_balance => statements(:tim).ending_balance, + :cleared => [account_items(:tim_checking_starting_balance).id] } + assert_response :missing + assert statements(:tim, :reload).account_items.empty? + end + + test "update should load statement and update statement and redirect" do + assert statements(:john_pending).account_items.empty? + put :update, :id => statements(:john_pending).id, + :statement => { :occurred_on => statements(:john_pending).occurred_on, + :ending_balance => statements(:john_pending).ending_balance, + :cleared => [account_items(:john_lunch_again_checking).id] } + assert_redirected_to account_url(statements(:john_pending).account) + assert_equal [account_items(:john_lunch_again_checking)], + statements(:john_pending, :reload).account_items + end + + test "destroy for inaccessible statement should 404" do + assert_no_difference "Statement.count" do + delete :destroy, :id => statements(:tim).id + assert_response :missing + end + end + + test "destroy should load and destroy statement and redirect" do + delete :destroy, :id => statements(:john).id + assert_redirected_to account_url(statements(:john).account) + assert !Statement.exists?(statements(:john).id) + end +end diff --git a/test/unit/statement_test.rb b/test/unit/statement_test.rb new file mode 100644 index 0000000..a4e69f7 --- /dev/null +++ b/test/unit/statement_test.rb @@ -0,0 +1,80 @@ +require 'test_helper' + +class StatementTest < ActiveSupport::TestCase + test "creation with ending balance as dollars should be translated to cents" do + statement = accounts(:john_checking).statements.create(:occurred_on => Date.today, + :ending_balance => "1,234.56") + assert_equal 1_234_56, statement.ending_balance + end + + test "creation with cleared ids should set statement id for given account items" do + items = [:john_checking_starting_balance, :john_bill_pay_checking].map { |i| account_items(i).id } + + statement = accounts(:john_checking).statements.create( + :occurred_on => Date.today, :ending_balance => 1_234_56, :cleared => items) + + assert_equal items, statement.account_items.map(&:id) + end + + test "creation with cleared ids should filter out items for different accounts" do + items = [:john_checking_starting_balance, :john_bill_pay_checking].map { |i| account_items(i).id } + bad_items = items + [account_items(:john_lunch_mastercard).id] + + statement = accounts(:john_checking).statements.create( + :occurred_on => Date.today, :ending_balance => 1_234_56, :cleared => bad_items) + + assert_equal items, statement.account_items.map(&:id) + end + + test "balanced_at should not be set when no items have been given" do + statement = accounts(:john_checking).statements.create(:occurred_on => Date.today, + :ending_balance => 1_234_56) + assert_nil statement.balanced_at + end + + test "balanced_at should be set automatically when items all balance" do + statements(:john).destroy # get this one out of the way + + items = [:john_checking_starting_balance, :john_bill_pay_checking].map { |i| account_items(i).id } + + statement = accounts(:john_checking).statements.create( + :occurred_on => Date.today, :ending_balance => 992_25, :cleared => items) + + assert_not_nil statement.balanced_at + assert (Time.now - statement.balanced_at) < 1 + end + + test "balanced_at should be cleared automatically when items do not balance" do + items = [:john_checking_starting_balance, :john_bill_pay_checking].map { |i| account_items(i).id } + + statement = accounts(:john_checking).statements.create(:occurred_on => Date.today, + :ending_balance => 1_234_56) + statement.balanced_at = Time.now.utc + statement.save + + assert_not_nil statement.reload.balanced_at + + statement.update_attributes :cleared => items + assert_nil statement.reload.balanced_at + end + + test "deleting statement should nullify association with account items" do + assert statements(:john).account_items.any? + assert_equal statements(:john), account_items(:john_checking_starting_balance).statement + + assert_difference "Statement.count", -1 do + assert_no_difference "AccountItem.count" do + statements(:john).destroy + end + end + + assert_nil account_items(:john_checking_starting_balance, :reload).statement + end + + test "balanced should be true when unsettled balance is zero" do + assert statements(:john).balanced? + statements(:john).update_attributes :ending_balance => 1234_56, + :cleared => statements(:john).account_items.map(&:id) + assert !statements(:john).balanced?(true) + end +end